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
+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;