fix: align pending questions by native occurrence
This commit is contained in:
+33
-22
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user