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)
This commit is contained in:
amosblomqvist
2026-10-03 01:51:13 +03:00
parent 3e5b857c63
commit 673a430d6e
12 changed files with 409 additions and 12 deletions
+40 -1
View File
@@ -32,7 +32,7 @@ import {
newAnswerResult,
newUserMessage,
} from "./native-messages.ts";
import { QUESTION_TOOLS } from "./pending.ts";
import { QUESTION_TOOLS, toolQuestionKind } from "./pending.ts";
/** One client-originated input event (the POST body's `input`). */
export type ChatInput =
@@ -50,6 +50,45 @@ export type SteerInput = { type: "steers"; steers: LhMessage[] };
/** Every input the turn composition accepts. */
export type TurnInput = ChatInput | SteerInput;
/** The dismissal event (owner design 2026-10-03, "A2"): NEVER composes a
* turn — the silent route appends + persists and wakes nothing. The
* answer rides the event (mirroring the answer event) but must actually
* BE a dismissal: the silent path must never inject a live answer
* wake-free. Parallel question calls each get their own dismiss event;
* results reference their calls by id, so the appends stay independent. */
export type DismissInput = { type: "dismiss"; toolCallId: string; answer: QuestionAnswerData };
/** Parse and validate a dismissal event off a request body. */
export function parseDismissInput(input: unknown): DismissInput {
if (typeof input !== "object" || input === null) throw new ChatInputError("input must be an object");
const i = input as Record<string, unknown>;
if (i.type !== "dismiss") throw new ChatInputError(`unknown input type: ${String(i.type)}`);
if (typeof i.toolCallId !== "string" || i.toolCallId.length === 0) {
throw new ChatInputError("dismiss input requires toolCallId");
}
const answer = i.answer as QuestionAnswerData | undefined;
if (typeof answer !== "object" || answer === null) {
throw new ChatInputError("dismiss input requires the dismissal answer object");
}
if (answer.dismissed !== true) {
throw new ChatInputError("dismiss input's answer must be a dismissal (dismissed: true)");
}
return { type: "dismiss", toolCallId: i.toolCallId, answer };
}
/** The native appends a dismissal makes (pure — same orphan discipline
* as answers: the call must be a DANGLING question call). */
export function dismissAppends(record: { messages: LhMessage[] }, input: DismissInput): LhMessage[] {
const pending = danglingQuestionOf(record.messages);
if (!pending || pending.toolCallId !== input.toolCallId) {
throw new ChatInputError("dismiss input targets no pending question call");
}
if (input.answer.kind !== toolQuestionKind(pending.tool)) {
throw new ChatInputError("dismiss input's answer kind does not match the question");
}
return [newAnswerResult(input.toolCallId, pending.tool, input.answer)];
}
/** A malformed input event (the honest 400 — the orphan-trap discipline). */
export class ChatInputError extends Error {}