parallel question gate: an answer that leaves a sibling dangling defers its wake (deepseek batch incident fix, owner 2026-10-03)
The incident: deepseek batched quiz + ask_user_question in one assistant message; the quiz executed first (sequential) and terminated the turn; the answer to the quiz IMMEDIATELY woke a reaction turn (fork-1 design assumed one dangling question max), the model complained about the unanswered ask, and the ask became permanently unanswerable — its toolResult could only append AFTER the intervening assistant message, a shape DeepSeek rejects. The gate: for answer inputs, if a question call still dangles after the append, persist write-ahead + emit record-changed + return the settled terminal — no turn. The dock advances to the sibling; the LAST answer fires the one reaction turn with every result adjacent to its calling message. Nothing else generates while a question dangles (job wakes wait, continuation folds refuse, the composer gates), so adjacency holds by construction. Also repaired the stranded session data (ask call stripped from the batch; backup at /tmp/chat_murkbeq2_backup.json).
This commit is contained in:
@@ -28,7 +28,9 @@ import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope,
|
||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
||||
import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
||||
import { hasProbeDone } from "../core/native-messages.ts";
|
||||
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
||||
import { emit } from "../core/events.ts";
|
||||
import { settledTurnResponse } from "./turn-runs.ts";
|
||||
import { asAgentMessages } from "../core/native-messages.ts";
|
||||
import {
|
||||
ChatInputError,
|
||||
@@ -228,6 +230,23 @@ export async function handleChatInput(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// THE PARALLEL-QUESTION GATE (owner, 2026-10-03 — the deepseek parallel
|
||||
// batch incident): an answer that leaves a SIBLING question dangling
|
||||
// defers its wake. The dock advances to the next question (the client's
|
||||
// optimistic result + record-changed carry the view); the LAST answer
|
||||
// fires the one reaction turn with every result ADJACENT to its calling
|
||||
// assistant message — the provider shape law (a tool result appended
|
||||
// after an intervening assistant message is rejected, which stranded
|
||||
// the sibling question permanently pre-fix). Nothing else can generate
|
||||
// while a question dangles (job wakes wait, continuation folds refuse,
|
||||
// the composer gates), so adjacency holds by construction.
|
||||
if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) {
|
||||
const merged = [...record.messages, ...appends];
|
||||
stores.chatStore.saveMessages(chatId, merged);
|
||||
emit("record-changed", { chatId });
|
||||
return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn
|
||||
}
|
||||
|
||||
// One turn per chat, ever. A second request while a turn runs means a
|
||||
// stale/remounted client raced a live one — letting it through would
|
||||
// double-run the teacher and clobber the first turn's persist.
|
||||
|
||||
+77
-2
@@ -1163,8 +1163,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> {
|
||||
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",
|
||||
@@ -1250,3 +1249,79 @@ describe("the silent dismissal (A2)", () => {
|
||||
assert.equal(endsInDismissal(chatStore.getChat(chat.id)!.messages), true, "paused until the user speaks");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parallel question calls (the deepseek batch incident, 2026-10-03) ─────
|
||||
|
||||
describe("parallel question calls: the answer defers its wake while a sibling dangles", () => {
|
||||
const ASK_INPUT = {
|
||||
question: "Which topic should this test session cover next?",
|
||||
options: [{ label: "Chemistry" }, { label: "History" }],
|
||||
};
|
||||
|
||||
/** Turn 1: BOTH calls in one assistant message (the parallel batch
|
||||
* shape deepseek emitted live). Turn 2: the reaction. */
|
||||
function parallelLesson(calls: string[][]): FauxResponseStep[] {
|
||||
return [
|
||||
(context) => {
|
||||
calls.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)));
|
||||
return fauxAssistantMessage(
|
||||
[
|
||||
fauxThinking("Batch a graded and a non-graded check."),
|
||||
fauxToolCall("quiz", QUIZ_INPUT, { id: "call-1" }),
|
||||
fauxToolCall("ask_user_question", ASK_INPUT, { id: "call-2" }),
|
||||
],
|
||||
{ stopReason: "toolUse" },
|
||||
);
|
||||
},
|
||||
(context) => {
|
||||
calls.push(context.messages.map((m) => JSON.stringify((m as { content: unknown }).content)));
|
||||
return fauxAssistantMessage([fauxText("Both answered — good.")]);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it("the sibling answer wakes NOTHING; the last answer fires the one turn with both results adjacent", async () => {
|
||||
const { engine, calls } = scriptedTeacher(parallelLesson);
|
||||
const chat = createConfiguredChat("Test: parallel questions");
|
||||
createdChats.push(chat.id);
|
||||
|
||||
// Turn 1: the batch lands; both calls dangle (the dock shows the quiz —
|
||||
// the first in call order).
|
||||
await drain(await handleChatRequest(post(chat.id, { type: "text", text: "ask me two questions in parallel" }), agentFactory(engine)));
|
||||
let saved = chatStore.getChat(chat.id)!;
|
||||
let pending = danglingQuestionOf(saved.messages);
|
||||
assert.equal(pending?.toolCallId, "call-1", "the dock holds the quiz first");
|
||||
|
||||
// The graded answer: the result appends ADJACENT to the calling
|
||||
// assistant message — and NO wake fires while the ask still dangles.
|
||||
const quizAns = gradedAnswer(QUIZ_INPUT, ["Mercury"]);
|
||||
await drain(await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer: quizAns }), agentFactory(engine)));
|
||||
assert.equal(calls.length, 1, "the sibling answer woke NOTHING (the gate deferred)");
|
||||
saved = chatStore.getChat(chat.id)!;
|
||||
assert.equal((saved.messages[2] as { toolCallId?: string }).toolCallId, "call-1", "quiz result adjacent to the batch");
|
||||
pending = danglingQuestionOf(saved.messages);
|
||||
assert.equal(pending?.toolCallId, "call-2", "the dock advances to the ask immediately — no turn ran");
|
||||
|
||||
// The last answer: ONE wake; the model sees both results, adjacent to
|
||||
// their calling message (the provider shape law).
|
||||
const askAns = {
|
||||
kind: "ask",
|
||||
questionText: ASK_INPUT.question,
|
||||
dontKnow: false,
|
||||
answers: [],
|
||||
text: "History",
|
||||
answeredAt: new Date().toISOString(),
|
||||
};
|
||||
await drain(await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-2", answer: askAns }), agentFactory(engine)));
|
||||
assert.equal(calls.length, 2, "exactly one wake: the last answer's turn");
|
||||
saved = chatStore.getChat(chat.id)!;
|
||||
assert.equal(danglingQuestionOf(saved.messages), null, "nothing dangles anymore");
|
||||
const prompt2 = calls[1]!.join("\n");
|
||||
assert.match(prompt2, /Mercury/, "the quiz result rode the wake's context");
|
||||
assert.match(prompt2, /History/, "the ask answer rode the wake's context");
|
||||
// The shape law: both results sit directly after the batch's assistant
|
||||
// message, before the reaction — nothing intervenes.
|
||||
const roles = saved.messages.map((m) => (m as { role?: string }).role);
|
||||
assert.deepEqual(roles.slice(1, 5), ["assistant", "toolResult", "toolResult", "assistant"], "batch → both results → reaction");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user