feat(runtime): add user-controlled turn recovery

This commit is contained in:
Xubin Ren
2026-08-24 00:58:04 +08:00
parent ffa58aa5ef
commit 12029f8812
60 changed files with 4027 additions and 169 deletions
+102
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media";
@@ -28,6 +29,7 @@ import {
} from "@/lib/thread-event-projection";
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import { readLocalPreferences } from "@/lib/local-preferences";
import type {
InboundEvent,
OutboundCliAppMention,
@@ -36,6 +38,7 @@ import type {
SessionMention,
GoalStateWsPayload,
MessageDeliveryStatus,
RecoveryState,
UIMediaAttachment,
UIMessage,
WorkspaceScopePayload,
@@ -244,6 +247,9 @@ export function useNanobotStream(
runStartedAt: number | null;
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
goalState: GoalStateWsPayload | undefined;
recoveryState: RecoveryState | null;
continueRecovery: () => Promise<void>;
dismissRecovery: () => Promise<void>;
send: (
content: string,
images?: SendAttachment[],
@@ -262,6 +268,7 @@ export function useNanobotStream(
dismissStreamError: () => void;
} {
const { client } = useClient();
const { t } = useTranslation();
const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId);
@@ -273,6 +280,7 @@ export function useNanobotStream(
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt);
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
const [recoveryState, setRecoveryState] = useState<RecoveryState | null>(null);
const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null);
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
@@ -288,6 +296,16 @@ export function useNanobotStream(
const dismissStreamError = useCallback(() => setStreamError(null), []);
const notifyInBackground = useCallback((body: string) => {
if (
typeof Notification === "undefined"
|| Notification.permission !== "granted"
|| document.visibilityState === "visible"
|| !readLocalPreferences().browserNotifications
) return;
new Notification("nanobot", { body });
}, []);
const clearPendingStreamWork = useCallback(() => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
@@ -639,6 +657,7 @@ export function useNanobotStream(
setStreamError(null);
setRunStartedAt(restoredRunStartedAt);
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
setRecoveryState(null);
buffer.current = null;
activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear();
@@ -846,10 +865,71 @@ export function useNanobotStream(
return finalized;
});
suppressStreamUntilTurnEndRef.current = false;
notifyInBackground(t("recovery.completed", { defaultValue: "Task completed" }));
onTurnEnd?.();
return;
}
if (ev.event === "recovery_state") {
const next: RecoveryState = {
status: ev.status,
recovery_id: ev.recovery_id,
...(ev.reason ? { reason: ev.reason } : {}),
...(typeof ev.attempts === "number" ? { attempts: ev.attempts } : {}),
...(typeof ev.can_continue === "boolean"
? { can_continue: ev.can_continue }
: {}),
};
setRecoveryState(next);
if (ev.status === "resuming") {
setRunStartedAt((current) => current ?? Date.now() / 1000);
setIsStreaming(true);
}
if (
ev.status === "awaiting_user"
|| ev.status === "recovered"
|| ev.status === "failed"
) {
// Recovery is an explicit boundary. The interrupted turn is no
// longer running, so do not let the stale start time keep the
// activity clock (or composer stop state) alive underneath the
// recovery notice.
setRunStartedAt(null);
setIsStreaming(false);
client.finishRunLocally(chatId);
clearPendingStreamWork();
closeActiveAssistantStream();
clearActivitySegment();
if (ev.status !== "recovered") {
notifyInBackground(
ev.status === "failed"
? t("recovery.failed", { defaultValue: "Task recovery failed" })
: t("recovery.interrupted", { defaultValue: "Task interrupted" }),
);
}
}
return;
}
if (ev.event === "attached") {
setRecoveryState(ev.recovery_state ?? null);
if (ev.recovery_state?.status === "resuming") {
setRunStartedAt((current) => current ?? Date.now() / 1000);
setIsStreaming(true);
} else if (
ev.recovery_state?.status === "awaiting_user"
|| ev.recovery_state?.status === "failed"
) {
setRunStartedAt(null);
setIsStreaming(false);
client.finishRunLocally(chatId);
clearPendingStreamWork();
closeActiveAssistantStream();
clearActivitySegment();
}
return;
}
if (ev.event === "message") {
if (
suppressStreamUntilTurnEndRef.current &&
@@ -1062,8 +1142,10 @@ export function useNanobotStream(
ensureActivitySegmentId,
flushPendingStreamEvents,
isSideChannelEvent,
notifyInBackground,
onTurnEnd,
schedulePendingStreamFlush,
t,
]);
const send = useCallback(
@@ -1173,12 +1255,32 @@ export function useNanobotStream(
[client],
);
const recoveryAction = useCallback(async (action: "continue" | "dismiss") => {
if (!chatId || !recoveryState) return;
await client.requestMutation(`recovery.${action}`, {
chat_id: chatId,
recovery_id: recoveryState.recovery_id,
});
}, [chatId, client, recoveryState]);
const continueRecovery = useCallback(
() => recoveryAction("continue"),
[recoveryAction],
);
const dismissRecovery = useCallback(
() => recoveryAction("dismiss"),
[recoveryAction],
);
return {
messages,
messagesReady: messageOwnerChatId === chatId,
isStreaming,
runStartedAt,
goalState,
recoveryState,
continueRecovery,
dismissRecovery,
send,
transcribeAudio,
stop,