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:
@@ -325,11 +325,16 @@ export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
|||||||
) {
|
) {
|
||||||
start--;
|
start--;
|
||||||
}
|
}
|
||||||
// The dismissal rule (A2): a trailing batch that is ALL user dismissals
|
// The dismissal rule (A2 + owner ruling 2026-10-03, round-1 incident):
|
||||||
// owes the agent nothing — the session sits paused at the user's
|
// the batch's LAST resolution decides. Ending on a dismissal = the
|
||||||
// choice. Mixed batches (impossible while paused — held deliveries
|
// user signaled STOP — no wake, no reaction owed now; the answers'
|
||||||
// never append — but honest anyway) fall through to the normal rule.
|
// reactions ride the user's next plain message. Ending on a real
|
||||||
if (messages.slice(start).every(isDismissedResult)) return false;
|
// 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];
|
const owner = messages[start - 1];
|
||||||
if (!owner || (owner as { role?: string }).role !== "assistant") return false;
|
if (!owner || (owner as { role?: string }).role !== "assistant") return false;
|
||||||
const content = (owner as { content?: unknown }).content;
|
const content = (owner as { content?: unknown }).content;
|
||||||
|
|||||||
+76
-14
@@ -36,7 +36,7 @@ import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-
|
|||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { jobStore } from "../src/store/jobs.ts";
|
import { jobStore } from "../src/store/jobs.ts";
|
||||||
import { buildAnswer, coerceCorrectAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.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 { projectView } from "../src/core/view.ts";
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.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
|
/** BYOK turn gate (owner, 2026-09-22): no factory floor — a chat needs a
|
||||||
* conversation model before any turn streams. The scripted tests give
|
* conversation model before any turn streams. The scripted tests give
|
||||||
* each chat a per-chat pin exactly like the setup flow does in
|
* 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") ──────────────────
|
// ─── the silent dismissal (owner design 2026-10-03, "A2") ──────────────────
|
||||||
|
|
||||||
describe("the silent dismissal (A2)", () => { function dismissPost(chatId: string, toolCallId: string): Promise<Response> {
|
describe("the silent dismissal (A2)", () => {
|
||||||
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 () => {
|
it("writes the toolResult with NO wake — the next plain message carries it to the model", async () => {
|
||||||
const { engine, calls } = scriptedTeacher(defaultLesson);
|
const { engine, calls } = scriptedTeacher(defaultLesson);
|
||||||
const chat = createConfiguredChat("Test: silent dismissal");
|
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");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -854,7 +854,7 @@ describe("the dismissal pause (needsContinuation + endsInDismissal)", () => {
|
|||||||
assert.equal(needsContinuation(messages), false, "dismissed by id, not by position — the batch rule holds");
|
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 = {
|
const assistant = {
|
||||||
...assistantWithCall("a1", "call-a"),
|
...assistantWithCall("a1", "call-a"),
|
||||||
content: [
|
content: [
|
||||||
@@ -864,7 +864,20 @@ describe("the dismissal pause (needsContinuation + endsInDismissal)", () => {
|
|||||||
} as unknown as LhMessage;
|
} as unknown as LhMessage;
|
||||||
const messages = [userText("u1", "topic"), assistant, resultMsg("call-a", "quiz"), dismissedMsg("call-b")];
|
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(endsInDismissal(messages), true, "tail ends in the dismissal");
|
||||||
assert.equal(needsContinuation(messages), true, "a real answer in the batch still demands the reaction");
|
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: [
|
||||||
|
{ 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, 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)", () => {
|
it("any user message un-pauses (the derived tail flips)", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user