fix: align pending questions by native occurrence

This commit is contained in:
amosblomqvist
2026-10-28 23:31:37 +02:00
parent de9caf6bc4
commit 6ff4924780
9 changed files with 343 additions and 56 deletions
+35 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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.