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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user