From 82f0b59355efd425db6e2201c395f8267334fda0 Mon Sep 17 00:00:00 2001 From: Hadrien Cornier Date: Tue, 27 Oct 2026 22:57:35 -0500 Subject: [PATCH] Keep the question dock aligned with the native pending call --- components/chat.tsx | 3 ++- src/core/pending.ts | 12 ++++++---- tests/pending-authority.test.ts | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 tests/pending-authority.test.ts diff --git a/components/chat.tsx b/components/chat.tsx index 2d851d1..f48d816 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -70,6 +70,7 @@ import { CoverageRing } from "@/components/extraction-coverage"; import { Button } from "@/components/ui/button"; import { activeProblemOf, + danglingQuestionOf, dismissedAnswer, endsInFailedTurn, findPendingToolCall, @@ -444,7 +445,7 @@ export function Chat({ // mount-only by design (turnInFlight read at initial value) }, []); - const pending = findPendingToolCall(messages); + const pending = findPendingToolCall(messages, danglingQuestionOf(nativeMessages)?.toolCallId ?? null); // The abort handler reads the pending question from a ref (the window // listener is mount-scoped; the value it needs is render-scoped). const pendingRef = useRef(pending); diff --git a/src/core/pending.ts b/src/core/pending.ts index b1ffd1a..aa9134a 100644 --- a/src/core/pending.ts +++ b/src/core/pending.ts @@ -105,14 +105,14 @@ export function filterEnabledTools(tools: Record, enabled?: Enable return out; } -function findPending(messages: UIMessage[], names: readonly string[]): PendingToolCall | null { +function findPending(messages: UIMessage[], names: readonly string[], toolCallId?: string): PendingToolCall | null { for (let m = messages.length - 1; m >= 0; m--) { const msg = messages[m]!; for (let p = 0; p < msg.parts.length; p++) { const part = msg.parts[p]!; if (isToolUIPart(part) && part.state === "input-available") { const tool = part.type.slice("tool-".length); - if (names.includes(tool)) { + if (names.includes(tool) && (toolCallId === undefined || part.toolCallId === toolCallId)) { return { toolCallId: part.toolCallId as string, tool, input: part.input }; } } @@ -121,8 +121,12 @@ function findPending(messages: UIMessage[], names: readonly string[]): PendingTo return null; } -export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null { - return findPending(messages, QUESTION_TOOLS); +/** Grouped view messages lose native assistant boundaries. When the native + * record supplies an authority, expose only that call after view validation. + * null means no native question is pending; undefined keeps view-only callers. */ +export function findPendingToolCall(messages: UIMessage[], toolCallId?: string | null): PendingToolCall | null { + if (toolCallId === null) return null; + return findPending(messages, QUESTION_TOOLS, toolCallId); } // ─── Turn activity label (2026-09-19, the “Writing a question…” fix) ────── diff --git a/tests/pending-authority.test.ts b/tests/pending-authority.test.ts new file mode 100644 index 0000000..633f651 --- /dev/null +++ b/tests/pending-authority.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { findPendingToolCall } from "../src/core/pending.ts"; +import { danglingQuestionOf, newAnswerResult, type LhMessage } from "../src/core/native-messages.ts"; +import { inputAppends } from "../src/core/input-events.ts"; +import { projectView } from "../src/core/view.ts"; + +function assistant(id: string, ...calls: string[]): LhMessage { + return { id, role: "assistant", content: calls.map(id => ({ type: "toolCall", id, name: "quiz", arguments: { question: id } })), stopReason: "toolUse", timestamp: 1 } as LhMessage; +} + +function pending(messages: LhMessage[], view = projectView(messages)) { + return findPendingToolCall(view, danglingQuestionOf(messages)?.toolCallId ?? null); +} + +test("a later assistant question supersedes an unanswered question in the same projected group", () => { + const messages = [assistant("old", "old-question"), assistant("new", "new-question")]; + assert.equal(projectView(messages).length, 1, "the view merges both assistant boundaries"); + const question = pending(messages)!; + assert.equal(question.toolCallId, "new-question"); + assert.doesNotThrow(() => inputAppends({ messages }, { type: "answer", toolCallId: question.toolCallId, answer: { kind: "quiz", answers: [], dontKnow: true } as never })); +}); + +test("the newest assistant's parallel questions retain call order and never revive older questions", () => { + const messages = [assistant("old", "old-question"), assistant("new", "first", "second")]; + assert.equal(pending(messages)?.toolCallId, "first"); + messages.push({ ...newAnswerResult("first", "quiz", { kind: "quiz", answers: [], dontKnow: true } as never), id: "answer-first" }); + assert.equal(pending(messages)?.toolCallId, "second"); + messages.push({ ...newAnswerResult("second", "quiz", { kind: "quiz", answers: [], dontKnow: true } as never), id: "answer-second" }); + assert.equal(pending(messages), null); +}); + +test("the authoritative question still waits for projected input validation", () => { + const messages = [assistant("old", "old-question"), assistant("new", "new-question")]; + const view = projectView(messages); + for (const message of view) for (const part of message.parts) { + if (part.type === "tool-quiz" && part.toolCallId === "new-question") part.state = "input-streaming"; + } + assert.equal(pending(messages, view), null); +});