fix: preserve interaction replies across chat recovery
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user