diff --git a/app/api/chats/[id]/dismiss/route.ts b/app/api/chats/[id]/dismiss/route.ts new file mode 100644 index 0000000..98ee364 --- /dev/null +++ b/app/api/chats/[id]/dismiss/route.ts @@ -0,0 +1,58 @@ +/** + * 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 { + 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 }); +} diff --git a/components/abort-turn-dialog.tsx b/components/abort-turn-dialog.tsx index 90e8e01..64c6265 100644 --- a/components/abort-turn-dialog.tsx +++ b/components/abort-turn-dialog.tsx @@ -17,8 +17,10 @@ import { createPortal } from "react-dom"; * * What "abort" means is mode-dependent: from a running turn it kills * the turn (partial stays); from a pending question there is no turn to - * kill — abort IS dismissal (QuestionAnswerData.dismissed), and the - * teacher acknowledges the skip in its next turn like any answer. + * kill — abort IS dismissal (A2, 2026-10-03): the silent route writes + * the skipped toolResult and wakes NOTHING; the session pauses until + * the user's next plain message, which carries the dismissal to the + * model in its context. */ /** What Esc is aborting — drives copy and the confirm action. */ diff --git a/components/chat.tsx b/components/chat.tsx index d4bf8de..ff8b629 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -187,7 +187,7 @@ export function Chat({ // (core/view.ts projectView); sends are ONE explicit input event each // ({chatId, input} — the stash indirection died with the SDK), and the // turn stream is the VERBATIM AgentEvent passthrough the server emits. - const { messages, nativeMessages, send, submitAnswer, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = + const { messages, nativeMessages, send, submitAnswer, dismissQuestion, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = useChatSession({ chatId, initialMessages, initiallyInFlight }); // The reattach settled terminal → the sync-and-continue seam (below). @@ -323,14 +323,15 @@ export function Chat({ return; } // From a pending question there is no turn to kill — abort IS - // dismissal, riding the same answer path as any answer (the - // teacher acknowledges the skip in its next turn). + // dismissal (A2, 2026-10-03): the skipped toolResult lands via the + // SILENT route — no turn, no wake, nothing generates. The session + // pauses at the user's choice; the next plain send is the wake. const p = pendingRef.current; if (!p) return; const question = (p.input as { question?: string } | null)?.question ?? ""; - void submitAnswer(p.tool, p.toolCallId, dismissedAnswer(toolQuestionKind(p.tool), question)); + void dismissQuestion(p.toolCallId, dismissedAnswer(toolQuestionKind(p.tool), question)); }, - [abortInFlightTurn, submitAnswer], + [abortInFlightTurn, dismissQuestion], ); // Mount continuation: if the stored transcript ends with a fulfilled tool // (a job result landed while no client was open), resume the lesson — the @@ -879,6 +880,17 @@ export function Chat({ // Fully idle — a turn we never saw (every window missed): adopt now. void syncFromServer(); }); + useServerEvent("record-changed", (p) => { + // The silent dismissal write (A2, 2026-10-03): no turn ever ran, so + // only the record moved. Other tabs and post-error windows adopt; + // our OWN dismissal already rendered its optimistic append — syncing + // here too is harmless (the adoption law swaps local ids for the + // server's). Busy states ignore it: a running turn owns the view, + // and the write is 409-guarded against exactly that anyway. + if (p.chatId !== chatId) return; + if (localStreamRef.current || turnInFlightRef.current) return; + void syncFromServer(); + }); const onSend = () => { if (busy || pending) return; diff --git a/components/session-core.ts b/components/session-core.ts index 382aef0..29261aa 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -513,6 +513,30 @@ export class SessionModel { return this.send({ type: "answer", toolCallId, answer: output }); } + /** The dismissal path (owner design 2026-10-03, "A2"): the skipped + * toolResult lands optimistically, then the SILENT route persists it + * — no turn, no stream, nothing wakes. The session pauses at the + * user's choice; the next plain send is the wake. On failure the + * durable record wins: the error surfaces, the record-changed sync + * (or any later refetch) replaces the optimistic append with truth. */ + async dismissQuestion(toolCallId: string, answer: QuestionAnswerData): Promise { + if (!this.readyRef) return; + const tool = pendingCallTool([...this.base, ...this.tail], toolCallId) ?? "quiz"; + this.mutate(() => { + this.tail = [...this.tail, { ...newAnswerResult(toolCallId, tool, answer), id: localId() }]; + }); + try { + const res = await this.fetch(`/api/chats/${this.chatId}/dismiss`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: { type: "dismiss", toolCallId, answer } }), + }); + if (!res.ok) throw new Error(`The dismissal failed (${res.status}).`); + } catch (err) { + this.setError((err as Error).message); + } + } + /** Local detach: cancel the consume, keep the partial tail. The page's * settle watch adopts the record when the server turn finishes. */ stop(): void { diff --git a/components/session.ts b/components/session.ts index b0244bb..a2cca28 100644 --- a/components/session.ts +++ b/components/session.ts @@ -93,6 +93,10 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U void model.submitAnswer(tool, toolCallId, output), [model], ); + const dismissQuestion = useCallback( + (toolCallId: string, answer: QuestionAnswerData) => void model.dismissQuestion(toolCallId, answer), + [model], + ); const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]); const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]); const stop = useCallback(() => model.stop(), [model]); @@ -106,6 +110,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U error: state.error, send, submitAnswer, + dismissQuestion, setMessages, adoptRecord, stop, diff --git a/src/core/events.ts b/src/core/events.ts index bb02528..df53be8 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -38,6 +38,10 @@ export interface ServerEvents { "activity-changed": Record; /** The session list changed (create, delete, title). */ "chats-changed": Record; + /** 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. */ diff --git a/src/core/input-events.ts b/src/core/input-events.ts index 8ec4e64..fe264f4 100644 --- a/src/core/input-events.ts +++ b/src/core/input-events.ts @@ -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; + 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 {} diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 5d56f20..0e21fc8 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -292,6 +292,26 @@ export function answeredCallIds(messages: readonly LhMessage[]): Set { * 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; diff --git a/src/server/jobs.ts b/src/server/jobs.ts index 2c26e2e..184c0e0 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -64,7 +64,7 @@ import { type SteerMessage, } from "../core/contracts.ts"; import type { TurnInput } from "../core/input-events.ts"; -import { danglingQuestionOf } from "../core/native-messages.ts"; +import { danglingQuestionOf, endsInDismissal } from "../core/native-messages.ts"; import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts"; import { stores } from "./stores.ts"; import { isTurnRunning } from "./turn-liveness.ts"; @@ -457,6 +457,17 @@ async function pump(chatId: string): Promise { return; } + // THE DISMISSAL PAUSE (owner design 2026-10-03, "A2"): the record + // ends in a user dismissal — the session waits for the USER. Hold + // every marker in the queue (no wake fires) and return; the user's + // next send is the wake that carries them (the in-run vehicle below + // hands them to that turn once the text-claim lands — until then, + // settlement re-pumps and lands them right after it). The pause is + // derived from the tail, so any user message un-holds by construction. + if (endsInDismissal(fresh.messages)) { + return; + } + // DEQUEUE-AT-FIRE (the Wave C lesson from the Wave B regression): // the carried markers leave the registry BEFORE the turn runs — // the wake run's OWN follow-up poll must never see its own cargo, diff --git a/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index c238309..ddce8ab 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -35,11 +35,16 @@ import { buildSubagentSteer } from "../src/core/contracts.ts"; import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts"; import { chatStore } from "../src/store/chats.ts"; import { jobStore } from "../src/store/jobs.ts"; -import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; -import { danglingQuestionOf, newAnswerResult } from "../src/core/native-messages.ts"; +import { buildAnswer, coerceCorrectAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts"; +import { danglingQuestionOf, endsInDismissal, newAnswerResult } from "../src/core/native-messages.ts"; import { projectView } from "../src/core/view.ts"; import type { PiEngine } from "../src/agent/models.ts"; import type { LhMessage } from "../src/core/native-messages.ts"; + +// Route-level pin (the chat-birth-stamp pattern): the dismiss route's +// handler, driven directly. Env is the runner's (the same the static +// store imports already resolved under). +const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts"); import { PATHS } from "../config.ts"; // The key-presence turn gate (provider-key interlude, 2026-09-25): scripted @@ -1155,3 +1160,93 @@ it("explain tool call persists as a pending part; the free-text answer reveals t assert.ok(!/"explanation"/.test(seen), "the answer record carries no explanation field"); assert.equal(danglingQuestionOf(saved2.messages), null, "no pending question remains"); }); + +// ─── the silent dismissal (owner design 2026-10-03, "A2") ────────────────── + +describe("the silent dismissal (A2)", () => { + function dismissPost(chatId: string, toolCallId: string): Promise { + return dismissRoute.POST( + new Request(`http://test/api/chats/${chatId}/dismiss`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input: { type: "dismiss", toolCallId, answer: dismissedAnswer("quiz", QUIZ_INPUT.question) }, + }), + }), + { params: Promise.resolve({ id: chatId }) }, + ); + } + + it("writes the toolResult with NO wake — the next plain message carries it to the model", async () => { + const { engine, calls } = scriptedTeacher(defaultLesson); + const chat = createConfiguredChat("Test: silent dismissal"); + createdChats.push(chat.id); + + // Turn 1: the quiz dangles. + await drain(await handleChatRequest(post(chat.id, { type: "text", text: "teach me the solar system" }), agentFactory(engine))); + assert.ok(danglingQuestionOf(chatStore.getChat(chat.id)!.messages), "the quiz is pending"); + + // The dismissal: SILENT — record completes the call, nothing wakes. + const res = await dismissPost(chat.id, "call-1"); + assert.equal(res.status, 200); + const saved = chatStore.getChat(chat.id)!; + assert.equal(danglingQuestionOf(saved.messages), null, "the call is answered by the dismissal"); + const tail = saved.messages[saved.messages.length - 1]! as { role?: string; details?: { dismissed?: boolean } }; + assert.equal(tail.role, "toolResult"); + assert.equal(tail.details?.dismissed, true, "the record's dismissal is the details payload"); + assert.equal(calls.length, 1, "NO second engine call — the dismissal woke nothing"); + assert.equal(isTurnRunning(chat.id), false); + assert.equal(endsInDismissal(saved.messages), true, "the session sits dismissal-paused"); + + // The next plain message is THE wake — the model sees the skipped + // question and the user's text in one context (the steer-batch + // outcome, delivered by record truth). + await drain(await handleChatRequest(post(chat.id, { type: "text", text: "skip that — tell me about Mars" }), agentFactory(engine))); + assert.equal(calls.length, 2, "exactly one wake: the user's message"); + assert.match(calls[1]!.join("\n"), /dismissed/, "the dismissed result rode the wake's context"); + assert.equal(endsInDismissal(chatStore.getChat(chat.id)!.messages), false, "the pause lifted with the send"); + }); + + it("rejects honestly: unknown chat 404, no dangling question 400", async () => { + assert.equal((await dismissPost("chat_nonexistent", "call-x")).status, 404); + const chat = createConfiguredChat("Test: dismiss no question"); + createdChats.push(chat.id); + chatStore.saveMessages(chat.id, [{ id: "u1", role: "user", content: "hi", timestamp: 1 } as never]); + assert.equal((await dismissPost(chat.id, "call-x")).status, 400); + }); + + it("parallel quizzes dismiss call-by-call — the composer returns only when nothing dangles", async () => { + const chat = createConfiguredChat("Test: parallel dismiss"); + createdChats.push(chat.id); + // Two quiz calls in one assistant message (the sequential-execution + // shape the tools opt into). + chatStore.saveMessages(chat.id, [ + { id: "u1", role: "user", content: "hello", timestamp: 1 } as never, + { + id: "a1", + role: "assistant", + content: [ + { type: "toolCall", id: "call-a", name: "quiz", arguments: { question: "A?" } as never }, + { type: "toolCall", id: "call-b", name: "quiz", arguments: { question: "B?" } as never }, + ], + api: "openai-completions", + provider: "openrouter", + model: "test", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + stopReason: "toolUse", + timestamp: 1, + } as never, + ]); + + // First dismissal completes call-a; call-b still dangles. + assert.equal((await dismissPost(chat.id, "call-a")).status, 200); + const pending = danglingQuestionOf(chatStore.getChat(chat.id)!.messages); + assert.equal(pending?.toolCallId, "call-b", "the dock advances to the next question"); + assert.equal(endsInDismissal(chatStore.getChat(chat.id)!.messages), true); + + // Second dismissal: nothing dangles, still paused. + assert.equal((await dismissPost(chat.id, "call-b")).status, 200); + assert.equal(danglingQuestionOf(chatStore.getChat(chat.id)!.messages), null, "the composer may return"); + assert.equal(endsInDismissal(chatStore.getChat(chat.id)!.messages), true, "paused until the user speaks"); + }); +}); diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index 21b865e..1a04dc0 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -854,6 +854,61 @@ describe("wake pipeline (src/server/jobs.ts)", () => { assert.equal(jobStore.getJob(jobId)!.status, "done"); }); + it("the dismissal pause holds deliveries — no wake until the user's next turn settles (A2)", async () => { + const { chatId, jobId, steer } = setup(); + let fired = 0; + setTurnRunner(async () => { + fired++; + return 200; + }); + + // The record ends in a DISMISSAL (owner design 2026-10-03): the + // question was skipped via the silent route — no dangling call (the + // quiz gate would pass), so ONLY the dismissal pause holds the wake. + chatStore.saveMessages(chatId, [ + { id: "u1", role: "user", content: "hello", timestamp: 1 } as never, + { + id: "a1", + role: "assistant", + content: [{ type: "toolCall", id: "quiz-call", name: "quiz", arguments: { question: "q" } as never }], + api: "openai-completions", + provider: "openrouter", + model: "test", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + stopReason: "toolUse", + timestamp: 1, + } as never, + { + id: "r1", + role: "toolResult", + toolCallId: "quiz-call", + toolName: "quiz", + content: [{ type: "text", text: "skipped" }], + details: { kind: "quiz", questionText: "q", dontKnow: false, answers: [], dismissed: true, answeredAt: "t" }, + isError: false, + timestamp: 1, + } as never, + ]); + + const delivery = deliverJobResult(steer); + + await delay(1200); + assert.equal(fired, 0, "no wake while the session sits dismissal-paused"); + assert.equal(jobStore.getJob(jobId)!.status, "running", "the delivery holds (queued, not lost)"); + + // The user's next message is the wake — its turn settles, and the + // settlement re-pump lands the held delivery right after it. + const record = chatStore.getChat(chatId)!; + const afterUser = [...record.messages, { id: "u2", role: "user", content: "moving on", timestamp: 2 } as never]; + chatStore.saveMessages(chatId, afterUser); + settleSteers(chatId, afterUser); + + await waitFor(() => fired > 0, 8000, "wake after the pause lifts"); + await delivery; + await waitFor(() => jobStore.getJob(jobId)!.status !== "running", 8000, "job flip after the held wake"); + assert.equal(jobStore.getJob(jobId)!.status, "done"); + }); + it("retries with backoff on a 409 (a client turn raced in) and delivers on the retry", async () => { const { jobId, steer } = setup(); let calls = 0; diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index 6ed0b10..4c62784 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -24,7 +24,8 @@ import { afterEach, describe, it } from "node:test"; import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts"; import type { ChatInput } from "../src/core/input-events.ts"; import type { LhMessage } from "../src/core/native-messages.ts"; -import { newUserMessage, newAnswerResult, needsContinuation } from "../src/core/native-messages.ts"; +import { newUserMessage, newAnswerResult, needsContinuation, endsInDismissal } from "../src/core/native-messages.ts"; +import { dismissedAnswer } from "../src/core/question-core.ts"; import { buildAnswer, type QuestionData } from "../src/core/question-core.ts"; const realFetch = globalThis.fetch; @@ -817,6 +818,71 @@ describe("needsContinuation (the native lastAssistantMessageIsCompleteWithToolCa }); }); +// ─── the dismissal pause (owner design 2026-10-03, "A2") ──────────────────── + +describe("the dismissal pause (needsContinuation + endsInDismissal)", () => { + /** The record's silent dismiss write: the toolResult the dismiss route + * appends (full answer in details — the folds read it there). */ + function dismissedMsg(callId: string): LhMessage { + return { + ...newAnswerResult(callId, "quiz", dismissedAnswer("quiz", "Pick a planet")), + id: `d_${callId}`, + } as unknown as LhMessage; + } + + it("an all-dismissed tail demands NO continuation — the session waits for the user", () => { + const messages = [userText("u1", "topic"), assistantWithCall("a1", "call-q1", "quiz"), dismissedMsg("call-q1")]; + assert.equal(needsContinuation(messages), false); + assert.equal(endsInDismissal(messages), true, "the pause predicate reads the same tail"); + }); + + it("parallel dismissals: each silent write its own call — still paused", () => { + const assistant = { + ...assistantWithCall("a1", "call-a"), + content: [ + { type: "toolCall", id: "call-a", name: "quiz", arguments: {} }, + { type: "toolCall", id: "call-b", name: "ask_user_question", arguments: {} }, + ], + } as unknown as LhMessage; + const messages = [ + userText("u1", "topic"), + assistant, + dismissedMsg("call-a"), + { ...newAnswerResult("call-b", "ask_user_question", dismissedAnswer("ask", "Why?")), id: "d_call-b" } as unknown as LhMessage, + ]; + assert.equal(endsInDismissal(messages), true); + assert.equal(needsContinuation(messages), false, "dismissed by id, not by position — the batch rule holds"); + }); + + it("mixed tail (answered + dismissed) keeps the normal rule — the answer is owed its reaction", () => { + const assistant = { + ...assistantWithCall("a1", "call-a"), + content: [ + { type: "toolCall", id: "call-a", name: "quiz", arguments: {} }, + { type: "toolCall", id: "call-b", name: "quiz", arguments: {} }, + ], + } as unknown as LhMessage; + const messages = [userText("u1", "topic"), assistant, resultMsg("call-a", "quiz"), dismissedMsg("call-b")]; + assert.equal(endsInDismissal(messages), true, "tail ends in the dismissal"); + assert.equal(needsContinuation(messages), true, "a real answer in the batch still demands the reaction"); + }); + + it("any user message un-pauses (the derived tail flips)", () => { + const messages = [ + userText("u1", "topic"), + assistantWithCall("a1", "call-q1", "quiz"), + dismissedMsg("call-q1"), + userText("u2", "moving on"), + ]; + assert.equal(endsInDismissal(messages), false); + }); + + it("a normal answer is not a dismissal (the predicate stays honest)", () => { + const messages = [userText("u1", "topic"), assistantWithCall("a1", "call-q1", "quiz"), resultMsg("call-q1", "quiz")]; + assert.equal(endsInDismissal(messages), false); + }); +}); + // ─── the pending-call tool scan ───────────────────────────────────────────── describe("pendingCallTool", () => {