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
+38
View File
@@ -103,6 +103,24 @@ describe("ChatList", () => {
);
});
it("marks a conversation that needs recovery attention with a warning indicator", () => {
render(
<ChatList
sessions={[session({ chatId: "recovery", title: "Interrupted task" })]}
recoveryChatIds={["recovery"]}
activeKey="websocket:other"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
expect(screen.getByRole("img", { name: "This conversation needs your attention" }))
.toBeInTheDocument();
});
it("keeps handle columns intact inside grouped panes", () => {
render(
<ChatList
@@ -151,6 +169,26 @@ describe("ChatList", () => {
}
});
it("shows the running indicator while a recovery continuation is active", () => {
render(
<ChatList
sessions={[session({ chatId: "recovery", title: "Interrupted task" })]}
runningChatIds={["recovery"]}
recoveryChatIds={["recovery"]}
activeKey="websocket:other"
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
expect(screen.getByRole("img", { name: "Agent running" })).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "This conversation needs your attention" }))
.not.toBeInTheDocument();
});
it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
render(
<ChatList
+13
View File
@@ -161,6 +161,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.rows.fileEditDisplay",
"settings.rows.codeWrap",
"settings.rows.brandLogos",
"settings.rows.browserNotifications",
"settings.rows.currentModel",
"settings.rows.localServiceAccess",
"settings.rows.webuiDefaultAccess",
@@ -172,6 +173,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.help.fileEditDisplay",
"settings.help.codeWrap",
"settings.help.brandLogos",
"settings.help.browserNotifications",
"settings.help.currentModel",
"settings.help.localServiceAccess",
"settings.help.webuiDefaultAccess",
@@ -252,6 +254,7 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
"chat.activity.running",
"chat.activity.complete",
"chat.activity.updated",
"chat.activity.recovery",
"chat.pin",
"chat.unpin",
"chat.rename",
@@ -293,6 +296,16 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
"message.skill",
"settings.channels.connectionChecks",
"settings.channels.open",
"recovery.actionFailed",
"recovery.interrupted",
"recovery.completed",
"recovery.failed",
"recovery.failedHelp",
"recovery.resuming",
"recovery.review",
"recovery.safeResume",
"recovery.dismiss",
"recovery.continue",
];
const ACCIDENTALLY_SPANISH_SETTINGS_KEYS = [
"settings.help.provider",
+19
View File
@@ -0,0 +1,19 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_LOCAL_PREFS,
readLocalPreferences,
writeLocalPreferences,
} from "@/lib/local-preferences";
describe("local preferences", () => {
beforeEach(() => localStorage.clear());
it("keeps browser notifications opt-in", () => {
expect(DEFAULT_LOCAL_PREFS.browserNotifications).toBe(false);
expect(readLocalPreferences().browserNotifications).toBe(false);
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, browserNotifications: true });
expect(readLocalPreferences().browserNotifications).toBe(true);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RecoveryNotice } from "@/components/thread/RecoveryNotice";
const INTERRUPTED = {
status: "awaiting_user" as const,
recovery_id: "recovery-1",
reason: "tool_state_uncertain",
};
describe("RecoveryNotice", () => {
it("hides the internal resuming state after Continue is accepted", async () => {
const onContinue = vi.fn().mockResolvedValue(undefined);
render(
<RecoveryNotice
state={INTERRUPTED}
onContinue={onContinue}
onDismiss={vi.fn().mockResolvedValue(undefined)}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(onContinue).toHaveBeenCalledOnce();
});
it("uses the shared status surface and motion treatment", () => {
render(
<RecoveryNotice
state={{ status: "resuming", recovery_id: "recovery-1" }}
onContinue={vi.fn().mockResolvedValue(undefined)}
onDismiss={vi.fn().mockResolvedValue(undefined)}
/>,
);
const notice = screen.getByRole("status");
expect(notice).toHaveAttribute("data-recovery-status", "resuming");
expect(notice).toHaveAttribute("aria-live", "polite");
expect(notice).toHaveClass(
"max-w-[49.5rem]",
"rounded-control",
"animate-in",
"fade-in-0",
"slide-in-from-bottom-1",
"duration-200",
"motion-reduce:animate-none",
);
});
it("keeps the notice visible when Continue fails", async () => {
const onContinue = vi.fn().mockRejectedValue(new Error("offline"));
render(
<RecoveryNotice
state={INTERRUPTED}
onContinue={onContinue}
onDismiss={vi.fn().mockResolvedValue(undefined)}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent("Recovery action failed");
});
});
it("shows the decision surface again when a continuation is interrupted", async () => {
const onContinue = vi.fn().mockResolvedValue(undefined);
const { rerender } = render(
<RecoveryNotice
state={INTERRUPTED}
onContinue={onContinue}
onDismiss={vi.fn().mockResolvedValue(undefined)}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
rerender(
<RecoveryNotice
state={{ status: "resuming", recovery_id: "recovery-1" }}
onContinue={onContinue}
onDismiss={vi.fn().mockResolvedValue(undefined)}
/>,
);
rerender(
<RecoveryNotice
state={{ ...INTERRUPTED, reason: "loop_guard" }}
onContinue={onContinue}
onDismiss={vi.fn().mockResolvedValue(undefined)}
/>,
);
await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument());
});
it("does not offer Continue when saved conversation context is unavailable", () => {
render(
<RecoveryNotice
state={{ ...INTERRUPTED, can_continue: false }}
onContinue={vi.fn().mockResolvedValue(undefined)}
onDismiss={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(screen.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
});
});
+28
View File
@@ -4007,4 +4007,32 @@ describe("ThreadShell", () => {
expect(screen.getByRole("option", { name: /Other project/i })).toBeInTheDocument();
});
it("allows a new turn after a completed recovery state", async () => {
const client = makeClient();
render(wrap(
client,
<ThreadShell
session={session("recovered-chat")}
title="Recovered chat"
onToggleSidebar={() => {}}
/>,
));
const input = await screen.findByLabelText("Message input");
act(() => {
client._emitChat("recovered-chat", {
event: "recovery_state",
chat_id: "recovered-chat",
recovery_id: "recovery-1",
status: "recovered",
});
});
fireEvent.change(input, { target: { value: "start the next task" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(client.sendMessage).toHaveBeenCalledOnce();
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
});
});
+98
View File
@@ -73,6 +73,7 @@ function fakeClient() {
const runStartedAtByChatId = new Map<string, number>();
const unsettledRunByChatId = new Map<string, boolean>();
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
const requestMutation = vi.fn().mockResolvedValue({});
let status: ConnectionStatus = "open";
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
@@ -133,6 +134,7 @@ function fakeClient() {
return () => set!.delete(h);
},
sendMessage: vi.fn(),
requestMutation,
finishRunLocally: vi.fn(),
newChat: vi.fn(),
forkChat: vi.fn(),
@@ -157,6 +159,7 @@ function fakeClient() {
setUnsettled(chatId: string, unsettled: boolean) {
unsettledRunByChatId.set(chatId, unsettled);
},
requestMutation,
};
}
@@ -395,6 +398,101 @@ describe("useNanobotStream", () => {
});
});
it("exposes typed recovery state and validates actions with its recovery id", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-recovery", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-recovery", {
event: "goal_status",
chat_id: "chat-recovery",
status: "running",
started_at: 1_700,
});
});
expect(result.current.runStartedAt).toBe(1_700);
act(() => {
fake.emit("chat-recovery", {
event: "recovery_state",
chat_id: "chat-recovery",
recovery_id: "recovery-1",
status: "awaiting_user",
reason: "tool_state_uncertain",
can_continue: false,
});
});
expect(result.current.recoveryState).toEqual({
recovery_id: "recovery-1",
status: "awaiting_user",
reason: "tool_state_uncertain",
can_continue: false,
});
expect(result.current.isStreaming).toBe(false);
expect(result.current.runStartedAt).toBeNull();
expect(fake.client.finishRunLocally).toHaveBeenCalledWith("chat-recovery");
act(() => {
fake.emit("chat-recovery", {
event: "recovery_state",
chat_id: "chat-recovery",
recovery_id: "recovery-1",
status: "awaiting_user",
reason: "tool_state_uncertain",
can_continue: true,
});
});
await act(async () => result.current.continueRecovery());
expect(fake.requestMutation).toHaveBeenCalledWith("recovery.continue", {
chat_id: "chat-recovery",
recovery_id: "recovery-1",
});
act(() => {
fake.emit("chat-recovery", {
event: "recovery_state",
chat_id: "chat-recovery",
recovery_id: "recovery-1",
status: "recovered",
});
});
expect(result.current.isStreaming).toBe(false);
expect(fake.client.finishRunLocally).toHaveBeenCalledWith("chat-recovery");
});
it("does not let historical recovered state clear a later active turn", () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-recovered-history", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
act(() => {
fake.emit("chat-recovered-history", {
event: "goal_status",
chat_id: "chat-recovered-history",
status: "running",
started_at: 1_700,
});
fake.emit("chat-recovered-history", {
event: "attached",
chat_id: "chat-recovered-history",
recovery_state: {
recovery_id: "old-recovery",
status: "recovered",
},
});
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.runStartedAt).toBe(1_700);
expect(fake.client.finishRunLocally).not.toHaveBeenCalled();
});
it("preserves proactive automation source metadata on complete assistant messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), {