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
}