fix: align pending questions by native occurrence
This commit is contained in:
@@ -35,7 +35,7 @@
|
||||
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||
import { QUESTION_TOOLS } from "./pending.ts";
|
||||
import { QUESTION_TOOLS, type PendingQuestion } from "./pending.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "./contracts/claude-code.ts";
|
||||
import {
|
||||
CONCLUDE_ACK_TEXT,
|
||||
@@ -673,16 +673,48 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
|
||||
export function danglingQuestionOf(
|
||||
messages: readonly LhMessage[],
|
||||
): { toolCallId: string; tool: string; input: unknown } | null {
|
||||
const question = questionOccurrenceOf(messages);
|
||||
return question ? { toolCallId: question.toolCallId, tool: question.tool, input: question.input } : null;
|
||||
}
|
||||
|
||||
/** The same authority, retaining native boundaries for the client dock.
|
||||
* Unique call ids are stable even when a live message's local id is replaced
|
||||
* by its persisted stamp. Only colliding ids need an occurrence-qualified
|
||||
* draft/reset key; this never changes stored ids or the answer wire. */
|
||||
export function pendingQuestionOf(messages: readonly LhMessage[]): PendingQuestion | null {
|
||||
const question = questionOccurrenceOf(messages);
|
||||
if (!question) return null;
|
||||
let occurrences = 0;
|
||||
for (const message of messages) {
|
||||
// A streamed c_ assistant can temporarily coexist with its saved
|
||||
// copy during append-only adoption. New live calls are already
|
||||
// uniquified; only settled history establishes reuse.
|
||||
if (message.role !== "assistant" || message.id.startsWith("c_")) continue;
|
||||
for (const call of message.content) {
|
||||
if (call.type === "toolCall" && call.id === question.toolCallId) occurrences++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...question,
|
||||
key: occurrences > 1 ? JSON.stringify([question.messageId, question.contentIndex, question.toolCallId]) : question.toolCallId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared native authority: newest assistant only, siblings in call order. */
|
||||
function questionOccurrenceOf(messages: readonly LhMessage[]): Omit<PendingQuestion, "key"> | null {
|
||||
for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]!;
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const assistant = m as AssistantMessage & { id: string };
|
||||
const answered = resultIdsFrom(messages.slice(mi + 1));
|
||||
for (const block of assistant.content) {
|
||||
for (const [contentIndex, block] of assistant.content.entries()) {
|
||||
if (block.type !== "toolCall") continue;
|
||||
if (!(QUESTION_TOOLS as readonly string[]).includes(block.name)) continue;
|
||||
if (answered.has(block.id)) continue;
|
||||
return { toolCallId: block.id, tool: block.name, input: block.arguments };
|
||||
return {
|
||||
toolCallId: block.id, tool: block.name, input: block.arguments,
|
||||
messageId: assistant.id, contentIndex,
|
||||
};
|
||||
}
|
||||
return null; // the newest assistant message's questions are all answered
|
||||
}
|
||||
|
||||
+29
-11
@@ -38,10 +38,18 @@ export interface PendingToolCall {
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
/** Client-only identity: projection may group several native assistants.
|
||||
* Never persisted or sent as part of an answer event. */
|
||||
export interface PendingQuestion extends PendingToolCall {
|
||||
messageId: string;
|
||||
contentIndex: number;
|
||||
/** Unique call ids stay stable across live → persisted message stamps.
|
||||
* Reused ids include their native occurrence instead. */
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** Tools that render in the question dock and gate the composer. The
|
||||
* probe-phase names (probe/recall, owner 2026-10-09) join their teach
|
||||
* twins — old records keep the teach names; new probe-phase calls carry
|
||||
* the phase names. */
|
||||
* probe-phase names join their teach twins; old records keep teach names. */
|
||||
export const QUESTION_TOOLS = [
|
||||
"quiz",
|
||||
"probe",
|
||||
@@ -105,14 +113,20 @@ export function filterEnabledTools<T>(tools: Record<string, T>, enabled?: Enable
|
||||
return out;
|
||||
}
|
||||
|
||||
function findPending(messages: UIMessage[], names: readonly string[], toolCallId?: string): PendingToolCall | null {
|
||||
function findPending(messages: UIMessage[], names: readonly string[], authority?: string | PendingQuestion): 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) && (toolCallId === undefined || part.toolCallId === toolCallId)) {
|
||||
if (!names.includes(tool)) continue;
|
||||
if (typeof authority === "object") {
|
||||
if (part.toolCallId !== authority.toolCallId || tool !== authority.tool ||
|
||||
part.sourceMessageId !== authority.messageId || part.sourceContentIndex !== authority.contentIndex) continue;
|
||||
return authority;
|
||||
}
|
||||
if (authority === undefined || part.toolCallId === authority) {
|
||||
return { toolCallId: part.toolCallId as string, tool, input: part.input };
|
||||
}
|
||||
}
|
||||
@@ -121,12 +135,16 @@ function findPending(messages: UIMessage[], names: readonly string[], toolCallId
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
/** Grouped view messages lose native assistant boundaries. Native callers
|
||||
* supply the exact occurrence, not just an id: its OWN part must have cleared
|
||||
* validation. Never fall back to an older available question.
|
||||
* null means no native pending question; undefined keeps view-only callers.
|
||||
* The string overload is retained for view-only/id-filter consumers. */
|
||||
export function findPendingToolCall(messages: UIMessage[], authority: PendingQuestion | null): PendingQuestion | null;
|
||||
export function findPendingToolCall(messages: UIMessage[], authority?: string | null): PendingToolCall | null;
|
||||
export function findPendingToolCall(messages: UIMessage[], authority?: string | PendingQuestion | null): PendingToolCall | null {
|
||||
if (authority === null) return null;
|
||||
return findPending(messages, QUESTION_TOOLS, authority);
|
||||
}
|
||||
|
||||
// ─── Turn activity label (2026-09-19, the “Writing a question…” fix) ──────
|
||||
|
||||
+8
-1
@@ -52,6 +52,9 @@ export interface LhViewPart extends Record<string, unknown> {
|
||||
text?: string;
|
||||
state?: string;
|
||||
toolCallId?: string;
|
||||
/** Native location, retained across display grouping (client-only). */
|
||||
sourceMessageId?: string;
|
||||
sourceContentIndex?: number;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
rawInput?: unknown;
|
||||
@@ -219,13 +222,17 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
|
||||
// back to a spinner mid-transcript; live cards belong at the
|
||||
// bottom, and this gate is what puts them there by construction).
|
||||
const streamingMessage = live?.streamingIds.has(m.id) === true;
|
||||
for (const block of assistant.content) {
|
||||
for (const [contentIndex, block] of assistant.content.entries()) {
|
||||
if (block.type === "text") {
|
||||
rest.push({ type: "text", id: `${m.id}-t${textN++}`, text: block.text, state: "done" });
|
||||
} else if (block.type === "thinking") {
|
||||
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
|
||||
} else if (block.type === "toolCall") {
|
||||
const part = toolPartOf(block, live, streamingMessage);
|
||||
if ((QUESTION_TOOLS as readonly string[]).includes(block.name)) {
|
||||
part.sourceMessageId = m.id;
|
||||
part.sourceContentIndex = contentIndex;
|
||||
}
|
||||
if (block.name === SEND_TO_USER_TOOL) {
|
||||
// Failed/aborted generations never execute their calls;
|
||||
// the provider error/abort UI already owns that outcome.
|
||||
|
||||
Reference in New Issue
Block a user