diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 0e21fc8..85234ee 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -325,11 +325,16 @@ 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; + // The dismissal rule (A2 + owner ruling 2026-10-03, round-1 incident): + // the batch's LAST resolution decides. Ending on a dismissal = the + // user signaled STOP — no wake, no reaction owed now; the answers' + // reactions ride the user's next plain message. Ending on a real + // answer = the reaction is owed. One law with the pump's pause + // (endsInDismissal reads the same last result), so continuation, + // delivery-holding, and the pause can never disagree. + if (isDismissedResult(messages[messages.length - 1]!)) { + 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/tests/chat-flow.test.ts b/tests/chat-flow.test.ts index e8a9292..4a3584a 100644 --- a/tests/chat-flow.test.ts +++ b/tests/chat-flow.test.ts @@ -36,7 +36,7 @@ import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn- import { chatStore } from "../src/store/chats.ts"; import { jobStore } from "../src/store/jobs.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 { danglingQuestionOf, endsInDismissal, needsContinuation, 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"; @@ -187,6 +187,21 @@ function post(chatId: string, input: unknown): Request { }); } +/** The silent-dismissal route POST (module scope — the parallel-rounds + * pins drive it too). */ +function dismissPost(chatId: string, toolCallId: string, kind: "quiz" | "ask" | "explain" = "quiz"): 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(kind, kind === "ask" ? "Next topic?" : QUIZ_INPUT.question) }, + }), + }), + { params: Promise.resolve({ id: chatId }) }, + ); +} + /** BYOK turn gate (owner, 2026-09-22): no factory floor — a chat needs a * conversation model before any turn streams. The scripted tests give * each chat a per-chat pin exactly like the setup flow does in @@ -1163,19 +1178,7 @@ it("explain tool call persists as a pending part; the free-text answer reveals t // ─── 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 }) }, - ); - } - +describe("the silent dismissal (A2)", () => { 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"); @@ -1325,3 +1328,62 @@ describe("parallel question calls: the answer defers its wake while a sibling da assert.deepEqual(roles.slice(1, 5), ["assistant", "toolResult", "toolResult", "assistant"], "batch → both results → reaction"); }); }); + +describe("parallel rounds: the LAST resolution decides the wake (owner ruling 2026-10-03)", () => { + /** The batch lesson, shared: turn 1 poses quiz + ask; turn 2 reacts. */ + function batchLesson(calls: string[][]): FauxResponseStep[] { + return [ + (context) => { + calls.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content))); + return fauxAssistantMessage( + [ + fauxThinking("Batch again."), + fauxToolCall("quiz", QUIZ_INPUT, { id: "call-1" }), + fauxToolCall("ask_user_question", { question: "Next topic?", options: [{ label: "Chemistry" }, { label: "History" }] }, { id: "call-2" }), + ], + { stopReason: "toolUse" }, + ); + }, + (context) => { + calls.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content))); + return fauxAssistantMessage([fauxText("Round done.")]); + }, + ]; + } + + it("round 1 — answer then dismiss: NO wake at all (ending on a dismissal pauses)", async () => { + const { engine, calls } = scriptedTeacher(batchLesson); + const chat = createConfiguredChat("Test: round 1"); + createdChats.push(chat.id); + await drain(await handleChatRequest(post(chat.id, { type: "text", text: "two questions please" }), agentFactory(engine))); + + // Answer the quiz — deferred by the parallel gate. + await drain(await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), agentFactory(engine))); + assert.equal(calls.length, 1, "the sibling answer deferred"); + + // Dismiss the ask — the LAST resolution is a dismissal: no continuation + // seam may fire either (the round-1 incident's leak, now closed). + assert.equal((await dismissPost(chat.id, "call-2", "ask")).status, 200); + const saved = chatStore.getChat(chat.id)!; + assert.equal(danglingQuestionOf(saved.messages), null); + assert.equal(needsContinuation(saved.messages), false, "ending on a dismissal owes nothing"); + assert.equal(endsInDismissal(saved.messages), true, "the session sits paused"); + assert.equal(calls.length, 1, "NO wake — the agent never ran a second turn"); + }); + + it("round 2 — dismiss then answer: exactly one wake carrying both results", async () => { + const { engine, calls } = scriptedTeacher(batchLesson); + const chat = createConfiguredChat("Test: round 2"); + createdChats.push(chat.id); + await drain(await handleChatRequest(post(chat.id, { type: "text", text: "two questions please" }), agentFactory(engine))); + + assert.equal((await dismissPost(chat.id, "call-1")).status, 200); + assert.equal(calls.length, 1, "the dismissal woke nothing"); + + await drain(await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-2", answer: { kind: "ask", questionText: "Next topic?", dontKnow: false, answers: [], text: "Chemistry", answeredAt: new Date().toISOString() } }), agentFactory(engine))); + assert.equal(calls.length, 2, "the trailing answer fired the one wake"); + const prompt2 = calls[1]!.join("\n"); + assert.match(prompt2, /Chemistry/, "the answer rode the wake"); + assert.match(prompt2, /dismissed/, "the dismissal rode the wake too"); + }); +}); diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index 4c62784..d3da13c 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -854,7 +854,20 @@ describe("the dismissal pause (needsContinuation + endsInDismissal)", () => { 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", () => { + it("mixed tail answered-then-dismissed: the LAST resolution decides — no wake (round-1 incident)", () => { + 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), false, "ending on a dismissal = the user signaled stop — the reaction rides the next message"); + }); + + it("mixed tail dismissed-then-answered: the answer IS owed its reaction (round-2 wake)", () => { const assistant = { ...assistantWithCall("a1", "call-a"), content: [ @@ -862,9 +875,9 @@ describe("the dismissal pause (needsContinuation + endsInDismissal)", () => { { 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"); + const messages = [userText("u1", "topic"), assistant, dismissedMsg("call-a"), resultMsg("call-b", "quiz")]; + assert.equal(endsInDismissal(messages), false); + assert.equal(needsContinuation(messages), true, "ending on a real answer = the reaction is owed"); }); it("any user message un-pauses (the derived tail flips)", () => {