fix: preserve interaction replies across chat recovery

This commit is contained in:
2026-10-29 17:53:33 +08:00
parent fbe273e90a
commit 52681a8451
38 changed files with 1623 additions and 314 deletions
+51 -2
View File
@@ -47,6 +47,7 @@ import {
normalizeTodoPriority,
normalizeTodoStatus,
normalizeToolParams,
sanitizeToolProgressInput,
normalizeToolStatus,
type PermissionRequestPayload,
type TodoUpdatePayload,
@@ -70,6 +71,8 @@ export {
export type ApprovalMode = "request" | "always";
type StreamPromptOptions = {
interactionMessageId?: string;
onHostQuestion?: (interactionId: string) => Promise<Record<string, unknown>>;
onPromptDispatched?: () => void;
onInboxEvent?: (type: string, data: { inboxID?: string; delivery?: "steer" | "queue" }) => void;
waitForUserInput?: () => Promise<boolean>;
@@ -98,6 +101,7 @@ type ProgressPayload = {
status: ProgressStatus;
title: string;
detail?: string;
tool?: { name: string; call_id?: string; input?: Record<string, unknown> };
};
const getPermissionTarget = (metadata: unknown) => {
@@ -158,10 +162,13 @@ export const streamPromptResponse = async ({
onPromptDispatched,
onInboxEvent,
waitForUserInput,
interactionMessageId,
onHostQuestion,
}: StreamPromptOptions): Promise<{
aborted: boolean;
failed: boolean;
toolCallCount: number;
waitingInput?: boolean;
}> => {
const eventStream = await runtime.subscribeEvents();
const iterator = eventStream[Symbol.asyncIterator]();
@@ -191,6 +198,7 @@ export const streamPromptResponse = async ({
let promptSettled = false;
let aborted = signal?.aborted ?? false;
let failed = false;
let waitingInput = false;
let finalAnswer: string | undefined;
const presentedAnswerIds = new Set<string>();
let finalAnswerUiBlockIds: string[] = [];
@@ -227,7 +235,7 @@ export const streamPromptResponse = async ({
})
: null;
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
const emitProgress = ({ id, phase, status, title, detail, tool }: ProgressPayload) => {
if (status === "running" && finalizedProgressIds.has(id)) {
return;
}
@@ -246,6 +254,7 @@ export const streamPromptResponse = async ({
status,
title,
detail,
tool,
started_at: startedAt,
elapsed_ms: Math.max(0, now - startedAt),
});
@@ -262,6 +271,7 @@ export const streamPromptResponse = async ({
status,
title,
detail,
tool,
started_at: startedAt,
ended_at: now,
duration_ms: durationMs,
@@ -278,7 +288,15 @@ export const streamPromptResponse = async ({
const promptPromise = Promise.resolve().then(async () => {
await beforePrompt?.();
await runtime.prompt(sessionId, message, toRuntimeModel(model));
if (interactionMessageId) {
const result = await runtime.continueInteraction(sessionId, interactionMessageId, message, toRuntimeModel(model), signal);
if (result === "completed") {
const recovered = await runtime.readInteractionResult(sessionId, interactionMessageId);
tokenSmoother.writeToken(recovered);
emittedText = true;
done = true;
}
} else await runtime.prompt(sessionId, message, toRuntimeModel(model));
onPromptDispatched?.();
})
.then(() => {
@@ -749,6 +767,30 @@ export const streamPromptResponse = async ({
});
}
if (part.type === "tool") {
if (part.tool === "ask_user") {
// Reserve every lifecycle state before generic tool bookkeeping:
// a running call must not consume its completed-question identity.
if (part.state.status === "completed" && !emittedToolParts.has(part.id)) {
const output = parseToolOutput(part.state.output);
if (!onHostQuestion || !isObjectRecord(output) || typeof output.interaction_id !== "string") {
throw new Error("Host question completed without a valid persisted interaction");
}
emittedToolParts.add(part.id);
const record = await onHostQuestion(output.interaction_id);
write("interaction_request", { ...record, session_id: clientSessionId });
await runtime.abortSession(sessionId);
await runtime.waitForSessionIdle(sessionId, undefined, true);
waitingInput = true;
done = true;
} else if (part.state.status === "error") {
emitProgress({
id: part.id, phase: "tool", status: "error",
title: getToolProgressTitle(part.tool, part.state.status),
detail: part.state.error,
});
}
continue;
}
if (part.tool === "present_answer") {
if (suppressAnswerUi) continue;
if (part.state.status === "completed" && !emittedToolParts.has(part.id)) {
@@ -863,6 +905,7 @@ export const streamPromptResponse = async ({
phase: "tool",
status: normalizeToolStatus(part.state.status),
title: getToolProgressTitle(part.tool, part.state.status),
tool: { name: part.tool, call_id: part.callID, input: sanitizeToolProgressInput(toolParams) },
detail: buildToolProgressDetail(
part.tool,
part.state.status,
@@ -1035,6 +1078,12 @@ export const streamPromptResponse = async ({
}
await promptPromise;
if (waitingInput) {
await tokenSmoother.flush();
emitProgress({ id: "request-completed", phase: "question", status: "completed", title: "等待你的回答", detail: "问题已保存,可以切换会话后继续回答。" });
write("done", { session_id: clientSessionId, reason: "waiting_input" });
return { aborted: false, failed: false, toolCallCount, waitingInput: true };
}
if (!emittedText && !finalAnswer) {
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
...debugContext,