From b62b6e09a1f0ea399d2efc71d8cf11da8ed956d7 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 14 Jun 2026 13:29:01 +0400 Subject: [PATCH] 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. --- .../apps/server/bridge/DomainEventMapper.kt | 10 +++++++ .../apps/server/protocol/ServerMessage.kt | 20 ++++++++++++++ .../ServerMessageSerializationTest.kt | 26 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt index bebb835d..18ada65c 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt @@ -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, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index f41de858..15bbba8f 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -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, + val originalRequest: String, + override val sequence: Long, + override val sessionSequence: Long, + ) : ServerMessage, SessionMessage + // -- System / infra -- @Serializable diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt index 328f91af..56b3ec9f 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt @@ -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(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 = """