feat(runtime): add user-controlled turn recovery
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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), {
|
||||
|
||||
Reference in New Issue
Block a user