From b43ab77eee1d10c4b20f8bd2422c352d661b4c46 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 19 Jul 2026 23:18:47 +0400 Subject: [PATCH] feat(server): REST /clarify route for headless clarification answers (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery-stage clarifications could only be answered over WebSocket (ClientMessage.ClarificationResponse), so the curl-based headless QA driver parked forever at discovery. Add POST /sessions/{id}/clarify mirroring approveStageRoute. The server resolves the live (stageId, requestId) from the session id via SessionOrchestrator.pendingClarificationFor() — the newest still-live, unanswered ClarificationRequestedEvent from the event log — so a curl caller need not know the requestId. Empty answers = free-text skip. Co-Authored-By: Claude Opus 4.8 --- .../apps/server/routes/ClarifyStageRoute.kt | 38 +++++++++++++++++++ .../apps/server/routes/SessionRoutes.kt | 1 + .../orchestration/SessionOrchestrator.kt | 18 +++++++++ .../src/test/kotlin/ClarificationLoopTest.kt | 33 ++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/routes/ClarifyStageRoute.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/ClarifyStageRoute.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/ClarifyStageRoute.kt new file mode 100644 index 00000000..e1476822 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/ClarifyStageRoute.kt @@ -0,0 +1,38 @@ +package com.correx.apps.server.routes + +import com.correx.apps.server.ServerModule +import com.correx.core.events.events.ClarificationAnswer +import com.correx.core.events.types.SessionId +import com.correx.core.utils.TypeId +import io.ktor.http.HttpStatusCode +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post +import kotlinx.serialization.Serializable + +// REST parity for stage clarifications (WS already has ClientMessage.ClarificationResponse). Keyed by +// session — the server resolves the live pending (stageId, requestId) so a headless run (a script, +// `correx run`) can clear a discovery-stage clarification without a WebSocket. If the caller omits the +// answer for a question, its value is the empty string (free-text skip). Fixes Vikunja #42. +@Serializable +data class ClarifyStageRequest(val answers: List) + +internal fun Route.clarifyStageRoute(module: ServerModule) { + post("/clarify") { + val id = call.parameters["id"] + if (id == null) { + call.respond(HttpStatusCode.BadRequest, "Missing session id") + return@post + } + val sessionId: SessionId = TypeId(id) + val pending = module.orchestrator.pendingClarificationFor(sessionId) + if (pending == null) { + call.respond(HttpStatusCode.NotFound, "No pending clarification for session $id") + return@post + } + val answers = call.receive().answers + module.orchestrator.submitClarification(sessionId, pending.stageId, pending.requestId, answers) + call.respond(HttpStatusCode.OK) + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt index 777c50fb..5d90ea7f 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt @@ -100,6 +100,7 @@ fun Route.sessionRoutes(module: ServerModule) { getSessionRoute(module) cancelSessionRoute(module) approveStageRoute(module) + clarifyStageRoute(module) approveSourcesRoute(module) undoSessionRoute(module) resumeSessionRoute(module) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 76439c49..d437760d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -9,6 +9,8 @@ import com.correx.core.artifactstore.ArtifactStore import com.correx.core.context.builder.ContextPackBuilder import com.correx.core.context.model.ContextPack import com.correx.core.events.events.ClarificationAnswer +import com.correx.core.events.events.ClarificationAnsweredEvent +import com.correx.core.events.events.ClarificationRequestedEvent import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceFailedEvent import com.correx.core.events.events.InferenceStartedEvent @@ -260,6 +262,22 @@ abstract class SessionOrchestrator( fun liveClarificationRequestIds(): Set = pendingClarifications.keys.mapTo(mutableSetOf()) { it.value } + /** + * The clarification a headless caller should answer for this session: the newest still-live, + * unanswered request. Null if the session is not parked on a clarification. Lets a REST answer + * route resolve the (stageId, requestId) server-side so a script need only supply answer values — + * the WS path knows them because it received the [ClarificationRequestedEvent]; a curl caller does not. + */ + suspend fun pendingClarificationFor(sessionId: SessionId): ClarificationRequestedEvent? { + val live = liveClarificationRequestIds() + if (live.isEmpty()) return null + val events = eventStore.read(sessionId) + val answered = events.mapNotNull { it.payload as? ClarificationAnsweredEvent } + .mapTo(mutableSetOf()) { it.requestId } + return events.mapNotNull { it.payload as? ClarificationRequestedEvent } + .lastOrNull { it.requestId.value in live && it.requestId !in answered } + } + /** Public seam for out-of-band gates (e.g. freestyle plan review) that reuse the stage-approval flow. */ suspend fun requestPlanApproval(sessionId: SessionId, preview: String): Boolean = requestStageApproval(sessionId, StageId("plan_review"), preview) diff --git a/testing/integration/src/test/kotlin/ClarificationLoopTest.kt b/testing/integration/src/test/kotlin/ClarificationLoopTest.kt index 21a82e81..1519e42b 100644 --- a/testing/integration/src/test/kotlin/ClarificationLoopTest.kt +++ b/testing/integration/src/test/kotlin/ClarificationLoopTest.kt @@ -181,6 +181,39 @@ class ClarificationLoopTest { ) } + @Test + fun `pendingClarificationFor resolves the live request server-side, then clears once answered`(): + Unit = runBlocking { + // Backs the REST /clarify route (#42): a headless caller has no ClarificationRequestedEvent, + // so the server must resolve (stageId, requestId) from just the session id, and stop + // resolving it once answered. + val sessionId = SessionId("clarify-rest") + val orchestrator = orchestrator(listOf(withQuestions, clean)) + val runJob = launch { orchestrator.run(sessionId, graph(), config) } + + val pending = withTimeout(5_000) { + var p: ClarificationRequestedEvent? = null + while (p == null) { + p = orchestrator.pendingClarificationFor(sessionId) + if (p == null) yield() + } + p + } + assertEquals(analystStage, pending.stageId) + assertEquals("Which stack?", pending.questions.single().prompt) + + orchestrator.submitClarification( + sessionId, pending.stageId, pending.requestId, + listOf(ClarificationAnswer(questionId = "stack", value = "React")), + ) + runJob.join() + + assertEquals( + null, orchestrator.pendingClarificationFor(sessionId), + "Answered clarification must no longer resolve as pending", + ) + } + @Test fun `a stage that emits questions parks once and is never re-run`(): Unit = runBlocking { val sessionId = SessionId("clarify-no-rerun")