Keep the question dock aligned with the native pending call
This commit is contained in:
+2
-1
@@ -70,6 +70,7 @@ import { CoverageRing } from "@/components/extraction-coverage";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
activeProblemOf,
|
activeProblemOf,
|
||||||
|
danglingQuestionOf,
|
||||||
dismissedAnswer,
|
dismissedAnswer,
|
||||||
endsInFailedTurn,
|
endsInFailedTurn,
|
||||||
findPendingToolCall,
|
findPendingToolCall,
|
||||||
@@ -444,7 +445,7 @@ export function Chat({
|
|||||||
// mount-only by design (turnInFlight read at initial value)
|
// 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
|
// The abort handler reads the pending question from a ref (the window
|
||||||
// listener is mount-scoped; the value it needs is render-scoped).
|
// listener is mount-scoped; the value it needs is render-scoped).
|
||||||
const pendingRef = useRef(pending);
|
const pendingRef = useRef(pending);
|
||||||
|
|||||||
+8
-4
@@ -105,14 +105,14 @@ export function filterEnabledTools<T>(tools: Record<string, T>, enabled?: Enable
|
|||||||
return out;
|
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--) {
|
for (let m = messages.length - 1; m >= 0; m--) {
|
||||||
const msg = messages[m]!;
|
const msg = messages[m]!;
|
||||||
for (let p = 0; p < msg.parts.length; p++) {
|
for (let p = 0; p < msg.parts.length; p++) {
|
||||||
const part = msg.parts[p]!;
|
const part = msg.parts[p]!;
|
||||||
if (isToolUIPart(part) && part.state === "input-available") {
|
if (isToolUIPart(part) && part.state === "input-available") {
|
||||||
const tool = part.type.slice("tool-".length);
|
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 };
|
return { toolCallId: part.toolCallId as string, tool, input: part.input };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,8 +121,12 @@ function findPending(messages: UIMessage[], names: readonly string[]): PendingTo
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null {
|
/** Grouped view messages lose native assistant boundaries. When the native
|
||||||
return findPending(messages, QUESTION_TOOLS);
|
* 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) ──────
|
// ─── Turn activity label (2026-09-19, the “Writing a question…” fix) ──────
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user