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:
amosblomqvist
2026-10-03 01:51:13 +03:00
parent 3e5b857c63
commit 673a430d6e
12 changed files with 409 additions and 12 deletions
+97 -2
View File
@@ -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<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 }) },
);
}
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");
});
});
+55
View File
@@ -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;
+67 -1
View File
@@ -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", () => {