Merge occurrence-aware pending-question submission fix

This commit is contained in:
amosblomqvist
2026-10-29 01:30:13 +02:00
9 changed files with 495 additions and 48 deletions
+11 -9
View File
@@ -72,6 +72,7 @@ import { CoverageRing } from "@/components/extraction-coverage";
import { Button } from "@/components/ui/button";
import {
activeProblemOf,
pendingQuestionOf,
dismissedAnswer,
endsInFailedTurn,
findPendingToolCall,
@@ -415,6 +416,7 @@ export function Chat({
void dismissQuestion(
p.toolCallId,
reading ? { ...answer, readingThinking: reading } : answer,
p.key,
);
if (busyRef.current) abortInFlightTurn();
return;
@@ -446,7 +448,7 @@ export function Chat({
// mount-only by design (turnInFlight read at initial value)
}, []);
const pending = findPendingToolCall(messages);
const pending = findPendingToolCall(messages, pendingQuestionOf(nativeMessages));
// The abort handler reads the pending question from a ref (the window
// listener is mount-scoped; the value it needs is render-scoped).
const pendingRef = useRef(pending);
@@ -535,7 +537,7 @@ export function Chat({
readingThinkingRef.current = "";
setReadingThinking("");
}, []);
const pendingQuestionId = pending?.toolCallId ?? null;
const pendingQuestionId = pending?.key ?? null;
useEffect(() => {
// Different calls never share a draft; a failed submission of THIS
// call restores the reading channel alongside the form's snapshot.
@@ -1677,8 +1679,8 @@ export function Chat({
fold) and NEVER overlaps the composer. */}
<DockOverlay
pending={pending}
initialQuestionDraft={pending ? questionDraft(pending.toolCallId)?.form : undefined}
onSaveQuestionDraft={(form) => { if (pending) saveQuestionDraft(pending.toolCallId, { form }); }}
initialQuestionDraft={pending ? questionDraft(pending.key)?.form : undefined}
onSaveQuestionDraft={(form) => { if (pending) saveQuestionDraft(pending.key, { form }); }}
practice={activeProblem}
compactionRunning={compacting}
onSubmitThinking={submitThinking}
@@ -1748,11 +1750,11 @@ export function Chat({
// ride the same callback (QuestionAnswerData.dismissed). The
// reading-thinking draft rides along (owner 2026-10-03) — the
// check answer is its only carrier.
saveQuestionDraft(pending.toolCallId, { readingThinking: readingThinkingRef.current });
saveQuestionDraft(pending.key, { readingThinking: readingThinkingRef.current });
const reading = readingThinkingRef.current.trim() || undefined;
// The pending-id effect clears this only when the question
// actually leaves. An offline preflight keeps the draft visible.
submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output);
submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output, pending.key);
}}
/>
</Conversation>
@@ -1933,7 +1935,7 @@ function DockOverlay({
setupCta,
queuedSteers,
}: {
pending: ReturnType<typeof findPendingToolCall>;
pending: ReturnType<typeof pendingQuestionOf>;
initialQuestionDraft?: QuestionFormDraft;
onSaveQuestionDraft: (draft: QuestionFormDraft) => void;
/** Compaction running for this chat (round 14) — the docks' submit
@@ -2000,7 +2002,7 @@ function DockOverlay({
const [revealed, setRevealed] = useState(false);
const revealPending = requiresReveal && !revealed;
const pendingId = pending?.toolCallId ?? null;
const pendingId = pending?.key ?? null;
const initialPendingRef = useRef(pendingId);
// Teach-question ARRIVAL state, applied during render — BEFORE the
@@ -2063,7 +2065,7 @@ function DockOverlay({
const shellRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const mode = pending
? `question:${pending.toolCallId}`
? `question:${pending.key}`
: practice
? `practice:${practice.toolCallId}`
: "chat";
+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);
}
}
+5 -5
View File
@@ -106,20 +106,20 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall
const continueIfOwed = useCallback((record: readonly LhMessage[]) => void model.continueIfOwed(record), [model]);
const steer = useCallback((text: string) => void model.steer(text), [model]);
const submitAnswer = useCallback(
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
void model.submitAnswer(tool, toolCallId, output),
(tool: string, toolCallId: string, output: QuestionAnswerData, questionKey?: string) =>
void model.submitAnswer(tool, toolCallId, output, questionKey),
[model],
);
const dismissQuestion = useCallback(
(toolCallId: string, answer: QuestionAnswerData) => void model.dismissQuestion(toolCallId, answer),
(toolCallId: string, answer: QuestionAnswerData, questionKey?: string) => void model.dismissQuestion(toolCallId, answer, questionKey),
[model],
);
const dismissProblem = useCallback(
(problemCallId: string) => void model.dismissProblem(problemCallId),
[model],
);
const questionDraft = useCallback((toolCallId: string) => model.questionDraft(toolCallId), [model]);
const saveQuestionDraft = useCallback((toolCallId: string, draft: Parameters<SessionModel["saveQuestionDraft"]>[1]) => model.saveQuestionDraft(toolCallId, draft), [model]);
const questionDraft = useCallback((questionKey: string) => model.questionDraft(questionKey), [model]);
const saveQuestionDraft = useCallback((questionKey: string, draft: Parameters<SessionModel["saveQuestionDraft"]>[1]) => model.saveQuestionDraft(questionKey, draft), [model]);
const setMessages = useCallback((record: LhMessage[], turnError?: string) => model.setMessages(record, turnError), [model]);
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
const stop = useCallback(() => model.stop(), [model]);