the last resolution decides the wake (owner ruling 2026-10-03, round-1 incident)

Live test finding: answer A then dismiss B woke the agent — the continuation
fold treated a mixed [answered, dismissed] trailing batch as 'reaction owed'
(all-dismissed rule only). Dismiss A then answer B and dismiss-both were
already correct.

The law now: the trailing batch's LAST resolution decides. Ending on a
dismissal = the user signaled stop — no wake, the answers' reactions ride the
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. The wrong
pin ('a real answer in the batch still demands the reaction') flipped.
This commit is contained in:
amosblomqvist
2026-10-03 02:27:44 +03:00
parent 7a5a55e94a
commit a3a048f572
3 changed files with 103 additions and 23 deletions
+10 -5
View File
@@ -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;
+76 -14
View File
@@ -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<Response> {
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<Response> {
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");
});
});
+17 -4
View File
@@ -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)", () => {