feat(server): workflow.proposed WS frame

Map WorkflowProposedEvent to a workflow.proposed ServerMessage carrying the
candidates + original request. No new ClientMessage: a pick reuses StartSession
(launch the chosen workflow seeded with the request) and the manual-answer slot
reuses ChatInput.
This commit is contained in:
2026-06-14 13:29:01 +04:00
parent fc373e11eb
commit b62b6e09a1
3 changed files with 56 additions and 0 deletions
@@ -41,6 +41,7 @@ import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.RouterNarrationEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowProposedEvent
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.ArtifactId
import org.slf4j.LoggerFactory
@@ -230,6 +231,15 @@ suspend fun domainEventToServerMessage(
sequence = seq,
sessionSequence = sessionSequence,
)
is WorkflowProposedEvent -> ServerMessage.WorkflowProposed(
sessionId = p.sessionId,
proposalId = p.proposalId,
prompt = p.prompt,
candidates = p.candidates,
originalRequest = p.originalRequest,
sequence = seq,
sessionSequence = sessionSequence,
)
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
// ApprovalDecisionResolvedEvent has no sessionId on its payload — read it from the event envelope
sessionId = event.metadata.sessionId,
@@ -3,6 +3,7 @@ package com.correx.apps.server.protocol
import com.correx.apps.server.metrics.MetricsReport
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ClarificationQuestion
import com.correx.core.events.events.ProposedWorkflow
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ClarificationRequestId
@@ -323,6 +324,25 @@ sealed interface ServerMessage {
override val sessionSequence: Long,
) : ServerMessage, SessionMessage
/**
* The router triaged a chat request to one or more candidate workflows (from a
* WorkflowProposedEvent). The client renders [candidates] as a choice panel; picking one launches
* that workflow via ClientMessage.StartSession seeded with [originalRequest], while the panel's
* manual-answer slot continues the conversation via ClientMessage.ChatInput. Non-authoritative:
* nothing runs until the operator picks.
*/
@Serializable
@SerialName("workflow.proposed")
data class WorkflowProposed(
val sessionId: SessionId,
val proposalId: String,
val prompt: String,
val candidates: List<ProposedWorkflow>,
val originalRequest: String,
override val sequence: Long,
override val sessionSequence: Long,
) : ServerMessage, SessionMessage
// -- System / infra --
@Serializable
@@ -216,6 +216,32 @@ class ServerMessageSerializationTest {
assert(jsonStr.contains("\"React\"")) { "expected options" }
}
@Test
fun `WorkflowProposed encodes type, candidates and original request`() {
val msg = ServerMessage.WorkflowProposed(
sessionId = SessionId("sess-w"),
proposalId = "prop-1",
prompt = "Run one of these?",
candidates = listOf(
com.correx.core.events.events.ProposedWorkflow(workflowId = "research", reason = "gather + report"),
com.correx.core.events.events.ProposedWorkflow(workflowId = "role_pipeline"),
),
originalRequest = "find papers on event sourcing",
sequence = 9,
sessionSequence = 4,
)
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
assert(jsonStr.contains("\"type\":\"workflow.proposed\"")) { "expected type=workflow.proposed" }
assert(jsonStr.contains("\"workflowId\":\"research\"")) { "expected candidate id" }
assert(jsonStr.contains("\"originalRequest\":\"find papers on event sourcing\"")) { "expected original request" }
val decoded = json.decodeFromString<ServerMessage.WorkflowProposed>(jsonStr)
assertEquals(2, decoded.candidates.size)
assertEquals("research", decoded.candidates.first().workflowId)
assertEquals("gather + report", decoded.candidates.first().reason)
assertEquals("", decoded.candidates[1].reason)
}
@Test
fun `ClarificationResponse decodes from the client wire format`() {
val wire = """