feat(server): REST /clarify route for headless clarification answers (#42)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ClarificationAnswer>)
|
||||
|
||||
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<ClarifyStageRequest>().answers
|
||||
module.orchestrator.submitClarification(sessionId, pending.stageId, pending.requestId, answers)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ fun Route.sessionRoutes(module: ServerModule) {
|
||||
getSessionRoute(module)
|
||||
cancelSessionRoute(module)
|
||||
approveStageRoute(module)
|
||||
clarifyStageRoute(module)
|
||||
approveSourcesRoute(module)
|
||||
undoSessionRoute(module)
|
||||
resumeSessionRoute(module)
|
||||
|
||||
+18
@@ -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<String> =
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user