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:
2026-07-19 23:18:47 +04:00
parent 95b16a5047
commit b43ab77eee
4 changed files with 90 additions and 0 deletions
@@ -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")