Files
learnharness/app/api/chats/[id]/dismiss/route.ts
T
amosblomqvist 673a430d6e question dismissal, A2: the silent write + the dismissal pause (owner design 2026-10-03)
- {type:"dismiss"} input event: parse + pure appends with the answer-event
  orphan discipline (dangling-call 400, kind match, answer must be dismissed)
- POST /api/chats/[id]/dismiss: the no-turn composition — write-ahead append,
  409 while a turn runs, emits record-changed (new bus word; idle views sync)
- needsContinuation: an all-dismissed trailing batch demands nothing;
  endsInDismissal = the derived pause predicate (tail-based, no latch)
- wake pipeline: while the record ends in a dismissal the pump holds every
  delivery in the steer queue — the user's next send is the wake (until the
  text-turn steer claim lands, settlement re-pump delivers right after it)
- session-core.dismissQuestion: optimistic skipped card + silent POST;
  chat.tsx confirmAbort question-mode swaps submitAnswer for it; ESC-abort
  popup everywhere (AbortTurnDialog) with popup-Esc claimed via state gate
- parallel question calls dismiss call-by-call (results bind by id)
2026-10-03 01:51:13 +03:00

59 lines
2.5 KiB
TypeScript

/**
* THE SILENT DISMISSAL ROUTE (owner design 2026-10-03, "A2"): ESC-abort
* from a pending question writes the dismissed toolResult to the record
* and wakes NOTHING — no beginTurn, no agent, no tokens. The session
* enters the derived dismissal pause (endsInDismissal): job deliveries
* hold in the wake pipeline's steer queue, the continuation fold demands
* nothing, and the user's next plain message is the wake that carries
* the dismissal (plus any held deliveries) into the agent's context.
*
* Parallel question calls: each dismissal completes exactly its own call
* (results bind by toolCallId, not position), so the dock advances
* call-by-call and the composer returns only when nothing dangles.
*
* Guards mirror the turn route's honesty: 404 unknown chat, 400 for an
* input that targets no dangling question call (or isn't a real
* dismissal), 409 when a turn is somehow running — the dismissing client
* heals via the record-changed sync, same as any raced settle.
*/
import { ChatInputError, dismissAppends, emit, parseDismissInput } from "@/src/core";
import { getChat, isTurnRunning, stores } from "@/src/server";
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }): Promise<Response> {
const { id } = await ctx.params;
const chat = getChat(id);
if (!chat) return Response.json({ error: "Unknown chat" }, { status: 404 });
let input;
try {
// The POST-family convention: the body is { input } (chatId lives in
// the URL here).
const body = (await req.json().catch(() => null)) as { input?: unknown } | null;
input = parseDismissInput(body?.input ?? null);
} catch (err) {
if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
throw err;
}
// A dismissal can only target a settled question (question tools end
// their turn) — a running turn means something raced; refuse, never
// append around a live turn's own persist.
if (isTurnRunning(id)) {
return Response.json({ error: "A turn is running for this session" }, { status: 409 });
}
let appends;
try {
appends = dismissAppends(chat, input);
} catch (err) {
if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
throw err;
}
// Write-ahead, turn-less: the record is the only state that moves.
const merged = [...chat.messages, ...appends];
stores.chatStore.saveMessages(id, merged);
emit("record-changed", { chatId: id });
return Response.json({ ok: true });
}