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
+33 -22
View File
@@ -17,8 +17,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import {
needsContinuation,
danglingQuestionOf,
answeredCallIds,
pendingQuestionOf,
failedResponseAtEnd,
measuredContextTokens,
newAnswerResult,
@@ -119,12 +118,13 @@ async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): Asyn
/** The dangling question call's tool name (the optimistic answer result's
* toolName — the server stamps the same from its own record scan). */
export function pendingCallTool(messages: readonly LhMessage[], toolCallId: string): string | null {
for (const m of messages) {
if ((m as { role?: string }).role !== "assistant") continue;
const content = (m as { content?: unknown }).content;
if (!Array.isArray(content)) continue;
for (const block of content as Array<{ type?: string; id?: string; name?: string }>) {
if (block.type === "toolCall" && block.id === toolCallId) return block.name ?? null;
// Match projection's newest-occurrence pairing for pre-uniquifier records.
for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]!;
if (m.role !== "assistant") continue;
for (let bi = m.content.length - 1; bi >= 0; bi--) {
const block = m.content[bi]!;
if (block.type === "toolCall" && block.id === toolCallId) return block.name;
}
}
return null;
@@ -644,9 +644,15 @@ export class SessionModel {
* POST + consume. Ready-gated: one turn per client at a time (the
* server's 409 + identity retry covers the wake-race window).
*/
async send(input: ChatInput, onAccepted?: () => void): Promise<boolean> {
async send(input: ChatInput, onAccepted?: () => void, questionKey?: string): Promise<boolean> {
const recovering = this.statusValue === "error";
if (!this.readyRef && !recovering) return false;
const subject = input.type === "answer" ? pendingQuestionOf([...this.base, ...this.tail]) : null;
// A render-scoped answer must not move to another occurrence sharing
// its id. This identity is local only; the server's wire is unchanged.
if (input.type === "answer" && questionKey !== undefined &&
(subject?.key !== questionKey || subject.toolCallId !== input.toolCallId)) return false;
const expectedKey = questionKey ?? (input.type === "answer" && subject?.toolCallId === input.toolCallId ? subject.key : undefined);
this.consumeAbort?.abort();
const controller = new AbortController();
this.consumeAbort = controller;
@@ -668,7 +674,8 @@ export class SessionModel {
await this.resumeStream(false);
return false;
}
if (input.type === "answer" && (answeredCallIds(record.messages).has(input.toolCallId) || danglingQuestionOf(record.messages)?.toolCallId !== input.toolCallId)) {
const current = pendingQuestionOf(record.messages);
if (current?.toolCallId !== input.toolCallId || (expectedKey !== undefined && current.key !== expectedKey)) {
// The call already resolved, or no longer owns the dock. Adopt
// truth, never POST that answer a second time.
this.setStatus("ready");
@@ -700,7 +707,7 @@ export class SessionModel {
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
const result = { ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() };
optimistic.push(result);
this.unconfirmedAnswers.set(result.id, input.toolCallId);
this.unconfirmedAnswers.set(result.id, expectedKey ?? input.toolCallId);
}
if (optimistic.length > 0) this.mutate(() => {
this.tail = [...this.tail, ...optimistic];
@@ -828,9 +835,9 @@ export class SessionModel {
/** The answer path (the dock): the answer IS the question call's tool
* result (fork 1), riding as its input event. */
submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise<void> {
submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData, questionKey?: string): Promise<void> {
void tool;
return this.send({ type: "answer", toolCallId, answer: output }).then(() => undefined);
return this.send({ type: "answer", toolCallId, answer: output }, undefined, questionKey).then(() => undefined);
}
/** The dismissal path (owner design 2026-10-03, "A2"): the skipped
@@ -839,8 +846,10 @@ export class SessionModel {
* user's choice; the next plain send is the wake. On failure the
* durable record wins: the error surfaces, the record-changed sync
* (or any later refetch) replaces the optimistic append with truth. */
async dismissQuestion(toolCallId: string, answer: QuestionAnswerData): Promise<void> {
async dismissQuestion(toolCallId: string, answer: QuestionAnswerData, questionKey?: string): Promise<void> {
if (!this.readyRef) return;
const subject = pendingQuestionOf([...this.base, ...this.tail]);
if (questionKey !== undefined && (subject?.key !== questionKey || subject.toolCallId !== toolCallId)) return;
const tool = pendingCallTool([...this.base, ...this.tail], toolCallId) ?? "quiz";
this.mutate(() => {
this.tail = [...this.tail, { ...newAnswerResult(toolCallId, tool, answer), id: localId() }];
@@ -890,19 +899,21 @@ export class SessionModel {
}
/** Submission snapshots outlive the dock, not the session mount. */
questionDraft(toolCallId: string): QuestionDraft | undefined {
return this.questionDrafts.get(toolCallId);
questionDraft(questionKey: string): QuestionDraft | undefined {
return this.questionDrafts.get(questionKey);
}
saveQuestionDraft(toolCallId: string, draft: Partial<QuestionDraft>): void {
this.questionDrafts.set(toolCallId, { ...this.questionDrafts.get(toolCallId), ...draft });
saveQuestionDraft(questionKey: string, draft: Partial<QuestionDraft>): void {
this.questionDrafts.set(questionKey, { ...this.questionDrafts.get(questionKey), ...draft });
}
private forgetResolvedDrafts(record: readonly LhMessage[]): void {
const answered = answeredCallIds(record);
for (const id of answered) this.questionDrafts.delete(id);
for (const [id, callId] of this.unconfirmedAnswers) {
if (answered.has(callId)) this.unconfirmedAnswers.delete(id);
const pendingKey = pendingQuestionOf(record)?.key;
for (const key of this.questionDrafts.keys()) {
if (key !== pendingKey) this.questionDrafts.delete(key);
}
for (const [id, key] of this.unconfirmedAnswers) {
if (key !== pendingKey) this.unconfirmedAnswers.delete(id);
}
}