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:
@@ -38,6 +38,10 @@ export interface ServerEvents {
|
||||
"activity-changed": Record<string, never>;
|
||||
/** The session list changed (create, delete, title). */
|
||||
"chats-changed": Record<string, never>;
|
||||
/** A chat's record changed WITHOUT a turn (the silent dismissal write).
|
||||
* Same notify-then-fetch contract as the turn events: open views of
|
||||
* the chat refetch and adopt the durable record. */
|
||||
"record-changed": { chatId: string };
|
||||
}
|
||||
|
||||
export type ServerEventType = keyof ServerEvents;
|
||||
@@ -51,6 +55,7 @@ export const SERVER_EVENT_TYPES = [
|
||||
"jobs-changed",
|
||||
"activity-changed",
|
||||
"chats-changed",
|
||||
"record-changed",
|
||||
] as const satisfies readonly ServerEventType[];
|
||||
|
||||
/** One notification: the type discriminates the payload. */
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -292,6 +292,26 @@ export function answeredCallIds(messages: readonly LhMessage[]): Set<string> {
|
||||
* dock's domain — fails the all-answered rule and never fires a turn,
|
||||
* exactly as the SDK's every(output-available) did).
|
||||
*/
|
||||
/** Is this record message a user-dismissed question result? The full
|
||||
* answer rides the toolResult's `details` (newAnswerResult) — the
|
||||
* dismissal pause and the continuation fold both read it. */
|
||||
function isDismissedResult(m: LhMessage): boolean {
|
||||
if ((m as { role?: string }).role !== "toolResult") return false;
|
||||
const details = (m as { details?: unknown }).details as { dismissed?: unknown } | undefined;
|
||||
return details?.dismissed === true;
|
||||
}
|
||||
|
||||
/** The dismissal pause (owner design 2026-10-03, "A2"): the record ends
|
||||
* in user dismissal — the session waits for the USER. While paused,
|
||||
* nothing auto-wakes: the continuation fold demands nothing (below) and
|
||||
* the wake pipeline holds job deliveries in the steer queue until the
|
||||
* next send. Derived from the tail — no latch to persist or clear; any
|
||||
* user message flips it off by changing the record. */
|
||||
export function endsInDismissal(messages: readonly LhMessage[]): boolean {
|
||||
const last = messages[messages.length - 1];
|
||||
return last !== undefined && isDismissedResult(last);
|
||||
}
|
||||
|
||||
export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last || (last as { role?: string }).role !== "toolResult") return false;
|
||||
@@ -305,6 +325,11 @@ export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
||||
) {
|
||||
start--;
|
||||
}
|
||||
// The dismissal rule (A2): a trailing batch that is ALL user dismissals
|
||||
// owes the agent nothing — the session sits paused at the user's
|
||||
// choice. Mixed batches (impossible while paused — held deliveries
|
||||
// never append — but honest anyway) fall through to the normal rule.
|
||||
if (messages.slice(start).every(isDismissedResult)) return false;
|
||||
const owner = messages[start - 1];
|
||||
if (!owner || (owner as { role?: string }).role !== "assistant") return false;
|
||||
const content = (owner as { content?: unknown }).content;
|
||||
|
||||
Reference in New Issue
Block a user