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 { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||||
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
||||||
import { persistTurn, triggerExtraction } from "./turn-service.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 { asAgentMessages } from "../core/native-messages.ts";
|
||||||
import {
|
import {
|
||||||
ChatInputError,
|
ChatInputError,
|
||||||
@@ -228,6 +230,23 @@ export async function handleChatInput(
|
|||||||
throw err;
|
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
|
// 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
|
// stale/remounted client raced a live one — letting it through would
|
||||||
// double-run the teacher and clobber the first turn's persist.
|
// 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") ──────────────────
|
// ─── the silent dismissal (owner design 2026-10-03, "A2") ──────────────────
|
||||||
|
|
||||||
describe("the silent dismissal (A2)", () => {
|
describe("the silent dismissal (A2)", () => { function dismissPost(chatId: string, toolCallId: string): Promise<Response> {
|
||||||
function dismissPost(chatId: string, toolCallId: string): Promise<Response> {
|
|
||||||
return dismissRoute.POST(
|
return dismissRoute.POST(
|
||||||
new Request(`http://test/api/chats/${chatId}/dismiss`, {
|
new Request(`http://test/api/chats/${chatId}/dismiss`, {
|
||||||
method: "POST",
|
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");
|
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