diff --git a/desktop/src/features/messages/lib/remoteAgentSession.test.mjs b/desktop/src/features/messages/lib/remoteAgentSession.test.mjs index 0f1b2f35abd..3cbfcb2c286 100644 --- a/desktop/src/features/messages/lib/remoteAgentSession.test.mjs +++ b/desktop/src/features/messages/lib/remoteAgentSession.test.mjs @@ -3,6 +3,8 @@ import { test } from "node:test"; import { publishedChannelMessage, publishedRemoteSession, + remoteSessionOffersCheckpoint, + remoteSessionRecoveryKind, usesThreadSandbox, } from "./remoteAgentSession.ts"; @@ -83,3 +85,94 @@ test("only an explicitly configured Kubernetes Sandbox uses thread launches", () ); assert.equal(usesThreadSandbox({ backend: { type: "local" } }), false); }); + +// Every lifecycle refusal the Kubernetes provider returns (sandbox.rs), plus +// errors that must not offer recovery. +const providerErrors = [ + [ + "Sandbox session ended (stopped); explicit recovery is required", + "recover", + true, + ], + ["Sandbox session ended; explicit recovery is required", "recover", true], + [ + "Sandbox Pod was replaced; credentials remain bound to the original session, explicit recovery is required", + "recover", + true, + ], + [ + "Sandbox is not an active managed session; explicit recovery is required", + "recover", + true, + ], + [ + "Sandbox session is ending; recover it explicitly once it has ended", + "ending", + true, + ], + [ + "this session has no verified checkpoint; start fresh instead", + "recover", + false, + ], + [ + "no ended session exists for this thread to recover; start fresh instead", + "recover", + false, + ], + [ + "recovery requires an ended session; this Sandbox is still active, so end it first", + "end-first", + true, + ], + [ + "Sandbox launch configuration differs; end the session, then explicit recovery is required", + "end-first", + true, + ], + [ + "Sandbox belongs to a different relay; use a new thread or explicit recovery", + null, + true, + ], + [ + "agent already has a Pod; stop and recover it explicitly before using Sandbox", + null, + true, + ], + ["provider timed out after 600s", null, true], + ["", null, true], +]; + +test("provider lifecycle errors map to the explicit action they ask for", () => { + for (const [error, kind, offersCheckpoint] of providerErrors) { + assert.equal(remoteSessionRecoveryKind(error), kind, error); + assert.equal( + remoteSessionRecoveryKind(error.toUpperCase()), + kind, + `case-insensitive: ${error}`, + ); + assert.equal(remoteSessionOffersCheckpoint(error), offersCheckpoint, error); + } +}); + +test("provider codes override prose and survive Tauri error wrapping", () => { + for (const [code, kind, checkpoint] of [ + ["session_active", "end-first", true], + ["config_differs", "end-first", true], + ["session_ending", "ending", true], + ["session_ended", "recover", true], + ["no_checkpoint", "recover", false], + ["no_ended_session", "recover", false], + ["future_refusal", null, true], + ]) { + const payload = { code, message: "session ended; start fresh instead" }; + for (const error of [ + payload, + Object.assign(new Error(payload.message), { payload }), + ]) { + assert.equal(remoteSessionRecoveryKind(error), kind, code); + assert.equal(remoteSessionOffersCheckpoint(error), checkpoint, code); + } + } +}); diff --git a/desktop/src/features/messages/lib/remoteAgentSession.ts b/desktop/src/features/messages/lib/remoteAgentSession.ts index 9bcd65f944d..1196381ad97 100644 --- a/desktop/src/features/messages/lib/remoteAgentSession.ts +++ b/desktop/src/features/messages/lib/remoteAgentSession.ts @@ -38,3 +38,81 @@ export function publishedRemoteSession( if (!/^[0-9a-f]{64}$/.test(threadRoot)) return undefined; return { channelId, threadRoot }; } + +/** The explicit action a thread-session error asks the user for. */ +export type RemoteSessionRecoveryKind = "recover" | "end-first" | "ending"; + +/** Preserve codes carried by both raw provider failures and Tauri errors. */ +export function remoteSessionErrorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null) return undefined; + if ("payload" in error) return remoteSessionErrorCode(error.payload); + return "code" in error && typeof error.code === "string" + ? error.code + : undefined; +} + +function errorMessage(error: unknown): string { + if (typeof error === "string") return error.toLowerCase(); + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ) + return error.message.toLowerCase(); + return ""; +} + +/** Codes take precedence; phrase matching is only for older providers. */ +export function remoteSessionRecoveryKind( + error: unknown, +): RemoteSessionRecoveryKind | null { + const code = remoteSessionErrorCode(error); + if (code !== undefined) { + switch (code) { + case "session_active": + case "config_differs": + return "end-first"; + case "session_ending": + return "ending"; + case "session_ended": + case "pod_ended": + case "pod_replaced": + case "session_not_running": + case "binding_expired": + case "recovery_interrupted": + case "no_checkpoint": + case "no_ended_session": + return "recover"; + default: + return null; + } + } + const message = errorMessage(error); + if (message.includes("end it first") || message.includes("end the session")) + return "end-first"; + if ( + message.includes("session is ending") || + message.includes("session is still ending") + ) + return "ending"; + if ( + message.includes("explicit recovery is required") || + message.includes("recover it explicitly once it has ended") || + message.includes("session ended") || + message.includes("start fresh instead") + ) + return "recover"; + return null; +} + +export function remoteSessionOffersCheckpoint(error: unknown): boolean { + const code = remoteSessionErrorCode(error); + if (code !== undefined) + return code !== "no_checkpoint" && code !== "no_ended_session"; + const message = errorMessage(error); + return !( + message.includes("no verified checkpoint") || + message.includes("no ended session exists") + ); +} diff --git a/desktop/src/features/messages/lib/remoteSessionRecoveryPrompts.test.mjs b/desktop/src/features/messages/lib/remoteSessionRecoveryPrompts.test.mjs new file mode 100644 index 00000000000..45e8d7ab02a --- /dev/null +++ b/desktop/src/features/messages/lib/remoteSessionRecoveryPrompts.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + clearRemoteSessionRecoveryPrompt, + getRemoteSessionRecoveryPrompts, + MAX_REMOTE_SESSION_RECOVERY_PROMPTS, + recordRemoteSessionRecoveryPrompt, + remoteSessionRecoveryPromptsForThread, + subscribeRemoteSessionRecoveryPrompts, + withRemoteSessionRecoveryPrompt, +} from "./remoteSessionRecoveryPrompts.ts"; + +const channelId = "f9ee507e-d03c-4570-baa6-02951c38e1cd"; + +function prompt(overrides = {}) { + return { + agentPubkey: "a".repeat(64), + agentName: "Agent", + session: { channelId, threadRoot: "b".repeat(64) }, + expectedRelayUrl: "wss://relay.example", + expectedSignerPubkey: "c".repeat(64), + error: "Sandbox session ended; explicit recovery is required", + ...overrides, + }; +} + +test("a newer failure for the same tenant, agent, and thread replaces the older one", () => { + const first = prompt(); + const second = prompt({ + agentPubkey: "A".repeat(64), + error: "Sandbox session is ending; recover it explicitly once it has ended", + }); + const next = withRemoteSessionRecoveryPrompt( + withRemoteSessionRecoveryPrompt([], first), + second, + ); + assert.deepEqual(next, [second]); + + const otherTenant = prompt({ expectedSignerPubkey: "d".repeat(64) }); + assert.equal(withRemoteSessionRecoveryPrompt(next, otherTenant).length, 2); +}); + +test("the prompt store stays bounded, keeping the newest failures", () => { + let prompts = []; + for ( + let index = 0; + index <= MAX_REMOTE_SESSION_RECOVERY_PROMPTS; + index += 1 + ) { + prompts = withRemoteSessionRecoveryPrompt( + prompts, + prompt({ + session: { + channelId, + threadRoot: index.toString(16).padStart(64, "0"), + }, + }), + ); + } + assert.equal(prompts.length, MAX_REMOTE_SESSION_RECOVERY_PROMPTS); + assert.equal(prompts[0].session.threadRoot, "1".padStart(64, "0")); +}); + +test("a thread selects only its own prompts", () => { + const here = prompt(); + const otherThread = prompt({ + session: { channelId, threadRoot: "e".repeat(64) }, + }); + const otherChannel = prompt({ + session: { + channelId: "00000000-0000-4000-8000-000000000000", + threadRoot: "b".repeat(64), + }, + }); + assert.deepEqual( + remoteSessionRecoveryPromptsForThread( + [here, otherThread, otherChannel], + here.session, + ), + [here], + ); +}); + +test("recording and clearing notify subscribers; clearing matches the start's scope", () => { + let notifications = 0; + const unsubscribe = subscribeRemoteSessionRecoveryPrompts(() => { + notifications += 1; + }); + const failed = prompt(); + recordRemoteSessionRecoveryPrompt(failed); + assert.deepEqual(getRemoteSessionRecoveryPrompts(), [failed]); + // A later successful start carries no error or name, only its scope. + clearRemoteSessionRecoveryPrompt({ + agentPubkey: failed.agentPubkey.toUpperCase(), + session: failed.session, + expectedRelayUrl: ` ${failed.expectedRelayUrl} `, + expectedSignerPubkey: failed.expectedSignerPubkey, + }); + assert.deepEqual(getRemoteSessionRecoveryPrompts(), []); + clearRemoteSessionRecoveryPrompt(failed); + assert.equal(notifications, 2, "clearing an absent prompt is silent"); + unsubscribe(); +}); diff --git a/desktop/src/features/messages/lib/remoteSessionRecoveryPrompts.ts b/desktop/src/features/messages/lib/remoteSessionRecoveryPrompts.ts new file mode 100644 index 00000000000..98e0c13c86e --- /dev/null +++ b/desktop/src/features/messages/lib/remoteSessionRecoveryPrompts.ts @@ -0,0 +1,103 @@ +import type { RemoteAgentSession } from "@/shared/api/remoteAgentSession"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * A thread-session start that failed with a lifecycle error. The thread panel + * offers the explicit recovery actions for it. Only the failure is kept: the + * user's recovery choice is sent with one command and never stored. + */ +export type RemoteSessionRecoveryPrompt = { + agentPubkey: string; + agentName: string; + session: RemoteAgentSession; + /** Tenant scope of the failed start; the recovery command asserts it. */ + expectedRelayUrl: string; + expectedSignerPubkey: string; + error: string; + code?: string; +}; + +/** Bounds the store: one entry per failed (tenant, agent, thread). */ +export const MAX_REMOTE_SESSION_RECOVERY_PROMPTS = 32; + +let prompts: readonly RemoteSessionRecoveryPrompt[] = []; +const listeners = new Set<() => void>(); + +/** Identity of a prompt: the tenant, the agent, and the thread. */ +export function remoteSessionRecoveryPromptKey( + prompt: Pick< + RemoteSessionRecoveryPrompt, + "agentPubkey" | "session" | "expectedRelayUrl" | "expectedSignerPubkey" + >, +): string { + return [ + prompt.expectedRelayUrl.trim(), + normalizePubkey(prompt.expectedSignerPubkey), + normalizePubkey(prompt.agentPubkey), + prompt.session.channelId, + prompt.session.threadRoot, + ].join("\u0000"); +} + +/** Pure update: replace any prompt with the same key, newest last, bounded. */ +export function withRemoteSessionRecoveryPrompt( + current: readonly RemoteSessionRecoveryPrompt[], + prompt: RemoteSessionRecoveryPrompt, +): readonly RemoteSessionRecoveryPrompt[] { + const key = remoteSessionRecoveryPromptKey(prompt); + return [ + ...current.filter( + (existing) => remoteSessionRecoveryPromptKey(existing) !== key, + ), + prompt, + ].slice(-MAX_REMOTE_SESSION_RECOVERY_PROMPTS); +} + +/** Pure selection: the prompts for one thread. */ +export function remoteSessionRecoveryPromptsForThread( + current: readonly RemoteSessionRecoveryPrompt[], + session: RemoteAgentSession, +): RemoteSessionRecoveryPrompt[] { + return current.filter( + (prompt) => + prompt.session.channelId === session.channelId && + prompt.session.threadRoot === session.threadRoot, + ); +} + +function setPrompts(next: readonly RemoteSessionRecoveryPrompt[]): void { + if (next === prompts) return; + prompts = next; + for (const listener of listeners) listener(); +} + +/** Records a failed thread-session start so its thread can offer recovery. */ +export function recordRemoteSessionRecoveryPrompt( + prompt: RemoteSessionRecoveryPrompt, +): void { + setPrompts(withRemoteSessionRecoveryPrompt(prompts, prompt)); +} + +/** Drops a prompt after its session started, recovered, or was dismissed. */ +export function clearRemoteSessionRecoveryPrompt( + prompt: Parameters[0], +): void { + const key = remoteSessionRecoveryPromptKey(prompt); + const next = prompts.filter( + (existing) => remoteSessionRecoveryPromptKey(existing) !== key, + ); + if (next.length !== prompts.length) setPrompts(next); +} + +export function subscribeRemoteSessionRecoveryPrompts( + listener: () => void, +): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getRemoteSessionRecoveryPrompts(): readonly RemoteSessionRecoveryPrompt[] { + return prompts; +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 506ce96969a..11958505ce0 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -1,4 +1,7 @@ -import type { PublishedMessageResult } from "@/features/messages/lib/remoteAgentSession"; +import { + type PublishedMessageResult, + publishedRemoteSession, +} from "@/features/messages/lib/remoteAgentSession"; import * as React from "react"; import { ArrowDown } from "lucide-react"; @@ -44,6 +47,7 @@ import { import type { ThreadDepthGuideAction } from "./MessageRow"; import { MessageThreadRow } from "./MessageThreadRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; +import { RemoteSessionRecoveryBanners } from "./RemoteSessionRecoveryBanner"; import { ThreadReplyRegion } from "./MessageThreadReplyState"; import { TypingIndicatorRow } from "./TypingIndicatorRow"; import { UnreadDivider } from "./UnreadDivider"; @@ -825,6 +829,17 @@ export function MessageThreadPanel({ hasConstrainedColumn ? { maxWidth: columnMaxWidthPx } : undefined } > +
+ +
{ + const dom = new JSDOM("", { + url: "http://localhost", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + let finishRecovery; + dom.window.__TAURI_INTERNALS__ = { + invoke: (command) => { + if (command === "remote_agent_session_supports_lifecycle") + return Promise.resolve(true); + if (command === "recover_remote_agent_session") + return new Promise((resolve) => { + finishRecovery = resolve; + }); + return Promise.reject(new Error(`unmocked command: ${command}`)); + }, + transformCallback: () => 1, + }; + globalThis.__TAURI_INTERNALS__ = dom.window.__TAURI_INTERNALS__; + const React = await import("react"); + const { render, act, fireEvent, waitFor } = await import( + "@testing-library/react" + ); + const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + ); + const { toast } = await import("sonner"); + const { setDetachedToastScope } = await import( + "../lib/detachedToastScope.ts" + ); + const { recordRemoteSessionRecoveryPrompt } = await import( + "../lib/remoteSessionRecoveryPrompts.ts" + ); + const { RemoteSessionRecoveryBanners } = await import( + "./RemoteSessionRecoveryBanner.tsx" + ); + const signer = "1".repeat(64); + const pubkey = "a".repeat(64); + const session = { + channelId: "11111111-2222-3333-4444-555555555555", + threadRoot: "b".repeat(64), + }; + setDetachedToastScope({ + relayUrl: "wss://tenant-a.example", + signerPubkey: signer, + }); + recordRemoteSessionRecoveryPrompt({ + agentPubkey: pubkey, + agentName: "Tenant A private agent", + session, + expectedRelayUrl: "wss://tenant-a.example", + expectedSignerPubkey: signer, + error: "Sandbox session ended; explicit recovery is required", + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const rendered = render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(RemoteSessionRecoveryBanners, { session }), + ), + ); + try { + await waitFor(() => + assert.ok(rendered.getByText("Recover from checkpoint")), + ); + fireEvent.click(rendered.getByText("Recover from checkpoint")); + assert.ok(finishRecovery); + // Switching communities unmounts this subtree, but not the global Toaster. + rendered.unmount(); + setDetachedToastScope({ + relayUrl: "wss://tenant-b.example", + signerPubkey: signer, + }); + await act(async () => { + finishRecovery({ + pubkey, + name: "Tenant A private agent", + status: "deployed", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.deepEqual( + toast.getToasts().map((entry) => String(entry.title ?? "")), + [], + "the previous community's agent name must not surface in the current community", + ); + } finally { + rendered.unmount(); + client.clear(); + dom.window.close(); + } +}); diff --git a/desktop/src/features/messages/ui/RemoteSessionRecoveryBanner.tsx b/desktop/src/features/messages/ui/RemoteSessionRecoveryBanner.tsx new file mode 100644 index 00000000000..90fd6479930 --- /dev/null +++ b/desktop/src/features/messages/ui/RemoteSessionRecoveryBanner.tsx @@ -0,0 +1,263 @@ +import * as React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { managedAgentsQueryKey } from "@/features/agents/hooks"; +import { matchesDetachedToastScope } from "@/features/messages/lib/detachedToastScope"; +import { + type RemoteSessionRecoveryKind, + remoteSessionOffersCheckpoint, + remoteSessionRecoveryKind, +} from "@/features/messages/lib/remoteAgentSession"; +import { + clearRemoteSessionRecoveryPrompt, + getRemoteSessionRecoveryPrompts, + type RemoteSessionRecoveryPrompt, + remoteSessionRecoveryPromptKey, + remoteSessionRecoveryPromptsForThread, + subscribeRemoteSessionRecoveryPrompts, +} from "@/features/messages/lib/remoteSessionRecoveryPrompts"; +import type { RemoteAgentSession } from "@/shared/api/remoteAgentSession"; +import { + endRemoteAgentSession, + type RemoteRecoveryMode, + type RemoteSessionStopState, + recoverRemoteAgentSession, + remoteAgentSessionSupportsLifecycle, +} from "@/shared/api/tauriManagedAgents"; +import { Alert, AlertDescription } from "@/shared/ui/alert"; +import { Button } from "@/shared/ui/button"; +import { getErrorMessage } from "./useMentionSendFlow.helpers"; + +type PromptView = { + kind: RemoteSessionRecoveryKind; + offersCheckpoint: boolean; + message: string; +}; + +const END_OUTCOMES: Record = { + ending: { + kind: "ending", + offersCheckpoint: true, + message: + "The session is ending. Recover it once it has saved its checkpoint.", + }, + ended: { + kind: "recover", + offersCheckpoint: true, + message: "The session has ended.", + }, + absent: { + kind: "recover", + offersCheckpoint: false, + message: "No session exists for this thread.", + }, +}; + +function RemoteSessionRecoveryBanner({ + prompt, +}: { + prompt: RemoteSessionRecoveryPrompt; +}) { + const queryClient = useQueryClient(); + const supportsLifecycle = useQuery({ + queryKey: ["remote-agent-session-lifecycle", prompt.agentPubkey], + queryFn: () => remoteAgentSessionSupportsLifecycle(prompt.agentPubkey), + staleTime: 60_000, + retry: false, + }); + // The latest action's outcome. Local to this prompt and dropped on unmount; + // the recovery mode itself is never kept anywhere. + const [outcome, setOutcome] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + const failure = { message: prompt.error, code: prompt.code }; + const initialKind = remoteSessionRecoveryKind(failure); + if (!initialKind) return null; + if (supportsLifecycle.isError) { + return ( + + {`${prompt.agentName}: Could not check remote session controls. ${getErrorMessage(supportsLifecycle.error, "Try again.")}`} + + + + ); + } + if (supportsLifecycle.data !== true) return null; + const view: PromptView = outcome ?? { + kind: initialKind, + offersCheckpoint: remoteSessionOffersCheckpoint(failure), + message: prompt.error, + }; + const tenant = { + expectedRelayUrl: prompt.expectedRelayUrl, + expectedSignerPubkey: prompt.expectedSignerPubkey, + }; + + const run = async (action: () => Promise) => { + setBusy(true); + try { + const next = await action(); + if (next) setOutcome(next); + } catch (error) { + // Keep the actions available: a failed attempt must not strand the + // user. A new lifecycle error retargets them (for example, recovery of + // a live session asks to end it first). + const message = getErrorMessage(error, "The action failed."); + setOutcome({ + kind: remoteSessionRecoveryKind(error) ?? view.kind, + offersCheckpoint: + remoteSessionRecoveryKind(error) !== null + ? remoteSessionOffersCheckpoint(error) + : view.offersCheckpoint, + message, + }); + } finally { + setBusy(false); + } + }; + + const recover = (mode: RemoteRecoveryMode) => + run(async () => { + await recoverRemoteAgentSession( + prompt.agentPubkey, + prompt.session, + mode, + tenant, + ); + clearRemoteSessionRecoveryPrompt(prompt); + void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + if ( + matchesDetachedToastScope( + prompt.expectedRelayUrl, + prompt.expectedSignerPubkey, + ) + ) { + toast.success( + mode === "checkpoint" + ? `${prompt.agentName} is restoring its workspace.` + : `${prompt.agentName} is starting a fresh workspace.`, + ); + } + return null; + }); + + const end = () => + run(async () => { + const state = await endRemoteAgentSession( + prompt.agentPubkey, + prompt.session, + tenant, + ); + return END_OUTCOMES[state]; + }); + + return ( + + + {`${prompt.agentName}: ${view.message}`} + +
+ {view.kind === "end-first" || view.kind === "ending" ? ( + + ) : ( + <> + {view.offersCheckpoint ? ( + + ) : null} + + + )} + +
+
+ ); +} + +/** + * Explicit end/recover actions for thread-sandbox starts in this thread that + * failed with a session lifecycle error. Shown only for the tenant that is on + * screen and only when the agent's provider advertises lifecycle recovery. + */ +export function RemoteSessionRecoveryBanners({ + session, +}: { + session: RemoteAgentSession | undefined; +}) { + const prompts = React.useSyncExternalStore( + subscribeRemoteSessionRecoveryPrompts, + getRemoteSessionRecoveryPrompts, + ); + if (!session) return null; + const visible = remoteSessionRecoveryPromptsForThread( + prompts, + session, + ).filter((prompt) => + matchesDetachedToastScope( + prompt.expectedRelayUrl, + prompt.expectedSignerPubkey, + ), + ); + if (visible.length === 0) return null; + return ( + <> + {visible.map((prompt) => ( + + ))} + + ); +} diff --git a/desktop/src/features/messages/ui/RemoteSessionRecoveryStates.test.mjs b/desktop/src/features/messages/ui/RemoteSessionRecoveryStates.test.mjs new file mode 100644 index 00000000000..0e102e3834a --- /dev/null +++ b/desktop/src/features/messages/ui/RemoteSessionRecoveryStates.test.mjs @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { after, afterEach, test } from "node:test"; +import { JSDOM } from "jsdom"; +const dom = new JSDOM("", { + url: "http://localhost", +}); +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, +}); +const React = await import("react"); +const { cleanup, fireEvent, render, waitFor } = await import( + "@testing-library/react" +); +const { QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" +); +const { toast } = await import("sonner"); +const { setDetachedToastScope } = await import("../lib/detachedToastScope.ts"); +const { clearRemoteSessionRecoveryPrompt, recordRemoteSessionRecoveryPrompt } = + await import("../lib/remoteSessionRecoveryPrompts.ts"); +const { RemoteSessionRecoveryBanners } = await import( + "./RemoteSessionRecoveryBanner.tsx" +); +after(() => dom.window.close()); + +const prompt = { + agentPubkey: "a".repeat(64), + agentName: "Thread agent", + session: { + channelId: "11111111-2222-3333-4444-555555555555", + threadRoot: "b".repeat(64), + }, + expectedRelayUrl: "wss://tenant-a.example", + expectedSignerPubkey: "1".repeat(64), + code: "session_active", + error: "A session is active.", +}; +let client; +afterEach(() => { + cleanup(); + client?.clear(); + clearRemoteSessionRecoveryPrompt(prompt); +}); + +function mount( + { + supports = async () => true, + end = async () => "ended", + recover = async () => ({}), + } = {}, + failure = {}, +) { + const calls = []; + window.__TAURI_INTERNALS__ = globalThis.__TAURI_INTERNALS__ = { + invoke(command, args) { + calls.push({ command, args }); + if (command === "remote_agent_session_supports_lifecycle") + return supports(); + if (command === "end_remote_agent_session") return end(); + if (command === "recover_remote_agent_session") return recover(); + return Promise.reject(new Error(`Unexpected command: ${command}`)); + }, + transformCallback: () => 1, + }; + setDetachedToastScope({ + relayUrl: prompt.expectedRelayUrl, + signerPubkey: prompt.expectedSignerPubkey, + }); + recordRemoteSessionRecoveryPrompt({ ...prompt, ...failure }); + client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return { + ...render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(RemoteSessionRecoveryBanners, { + session: prompt.session, + }), + ), + ), + calls, + }; +} + +for (const state of ["ending", "ended", "absent"]) { + test(`end session renders the ${state} outcome`, async () => { + let next = state; + const view = mount({ end: async () => next }); + await waitFor(() => assert.ok(view.getByText("End session"))); + fireEvent.click(view.getByText("End session")); + await waitFor(() => assert.ok(view.queryByText("End session") === null)); + if (state === "ending") { + assert.ok(view.queryByText("Recover from checkpoint") === null); + assert.ok(view.queryByText("Start fresh") === null); + next = "ended"; + fireEvent.click(view.getByText("Check session")); + await waitFor(() => assert.ok(view.getByText("Recover from checkpoint"))); + } else { + assert.ok(view.getByText("Start fresh")); + assert.equal( + Boolean(view.queryByText("Recover from checkpoint")), + state === "ended", + ); + } + for (const { args } of view.calls.filter( + (c) => c.command === "end_remote_agent_session", + )) { + assert.equal(args.expectedRelayUrl, prompt.expectedRelayUrl); + assert.equal(args.expectedSignerPubkey, prompt.expectedSignerPubkey); + } + }); +} + +test("typed recovery refusals retarget actions and suppress unavailable checkpoints", async () => { + let failure = { code: "no_checkpoint", message: "No snapshot is available." }; + const view = mount( + { + recover: async () => { + throw failure; + }, + }, + { code: "session_ended" }, + ); + await waitFor(() => assert.ok(view.getByText("Recover from checkpoint"))); + fireEvent.click(view.getByText("Recover from checkpoint")); + await waitFor(() => + assert.ok(view.queryByText("Recover from checkpoint") === null), + ); + assert.ok(view.getByText("Start fresh")); + failure = { code: "session_active", message: "A new session exists." }; + fireEvent.click(view.getByText("Start fresh")); + await waitFor(() => assert.ok(view.getByText("End session"))); + assert.ok(view.queryByText("Start fresh") === null); +}); + +test("capability failures remain visible and can be retried or dismissed", async () => { + let fail = true; + const view = mount({ + supports: async () => { + if (fail) throw new Error("Provider unavailable"); + return true; + }, + }); + await waitFor(() => assert.ok(view.getByText("Retry"))); + assert.match( + view.container.textContent, + /Could not check remote session controls/, + ); + assert.ok(view.queryByText("End session") === null); + fail = false; + fireEvent.click(view.getByText("Retry")); + await waitFor(() => assert.ok(view.getByText("End session"))); + fireEvent.click(view.getByText("Dismiss")); + await waitFor(() => assert.equal(view.container.textContent, "")); +}); + +test("unsupported providers offer no lifecycle actions", async () => { + const view = mount({ supports: async () => false }); + await waitFor(() => + assert.equal( + client.getQueryState([ + "remote-agent-session-lifecycle", + prompt.agentPubkey, + ])?.status, + "success", + ), + ); + assert.equal(view.container.textContent, ""); +}); + +for (const mode of ["checkpoint", "fresh"]) { + test(`${mode} recovery sends one mode and reports the matching outcome`, async () => { + const view = mount({}, { code: "session_ended" }); + await waitFor(() => assert.ok(view.getByText("Start fresh"))); + fireEvent.click( + view.getByText( + mode === "checkpoint" ? "Recover from checkpoint" : "Start fresh", + ), + ); + await waitFor(() => assert.equal(view.container.textContent, "")); + const calls = view.calls.filter( + (c) => c.command === "recover_remote_agent_session", + ); + assert.equal(calls.length, 1); + assert.equal(calls[0].args.mode, mode); + assert.match( + String(toast.getToasts().at(-1).title), + mode === "checkpoint" + ? /restoring its workspace/ + : /starting a fresh workspace/, + ); + }); +} diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs index 98196d14c2d..fbaa713df8b 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.test.mjs @@ -69,6 +69,8 @@ let startCalls = []; */ let heldStarts = []; let holdStarts = false; +/** When set, the next `start_managed_agent` rejects with this Tauri error. */ +let nextStartError = null; /** * Settlers for `get_identity` calls held open by `holdIdentity` — the window * before the identity query resolves, where the signer half of the scope is @@ -102,6 +104,11 @@ before(() => { } if (command === "start_managed_agent") { startCalls.push(args); + if (nextStartError !== null) { + const error = nextStartError; + nextStartError = null; + return Promise.reject(error); + } const started = { pubkey: args.pubkey, status: "running" }; if (!holdStarts) return Promise.resolve(started); return new Promise((resolve, reject) => { @@ -135,6 +142,7 @@ afterEach(async () => { beforeEach(async () => { startCalls = []; + nextStartError = null; heldStarts = []; holdStarts = false; heldIdentity = []; @@ -410,6 +418,74 @@ test("Sandbox wakes deduplicate within a thread and remain separate across threa rendered.unmount(); }); +test("a Sandbox start refused for an ended session offers recovery in its thread until a start succeeds", async () => { + const prompts = await import( + "@/features/messages/lib/remoteSessionRecoveryPrompts.ts" + ); + const { act, rendered } = await renderDetachedStart(); + const sandboxAgent = { + ...AGENT_RECORD, + backend: { type: "provider", id: "kubernetes", config: { sandbox: true } }, + }; + const session = { + channelId: "f9ee507e-d03c-4570-baa6-02951c38e1cd", + threadRoot: "c".repeat(64), + }; + const ended = { + code: "session_ended", + message: "The remote workspace stopped.", + }; + + // Neither a non-Sandbox agent nor a non-lifecycle error records a prompt. + nextStartError = ended; + await act(async () => { + rendered.result.current.startDetached(AGENT_RECORD, 1, session); + await settle(); + }); + nextStartError = "provider timed out after 600s"; + await act(async () => { + rendered.result.current.startDetached(sandboxAgent, 1, session); + await settle(); + }); + assert.deepEqual(prompts.getRemoteSessionRecoveryPrompts(), []); + + nextStartError = ended; + await act(async () => { + rendered.result.current.startDetached(sandboxAgent, 2, session); + await settle(); + }); + assert.deepEqual(prompts.getRemoteSessionRecoveryPrompts(), [ + { + agentPubkey: AGENT, + agentName: "fizz", + session, + expectedRelayUrl: RELAY_A, + expectedSignerPubkey: SELF, + error: ended.message, + code: ended.code, + }, + ]); + assert.ok( + (await toastTitles()).some((title) => title.includes(ended.message)), + ); + assert.ok( + (await toastTitles()).every( + (title) => !title.includes("Open the thread to recover"), + ), + ); + + // Recovery is never retried implicitly: the next ordinary start carries no + // recovery mode, and its success retires the prompt. + await act(async () => { + rendered.result.current.startDetached(sandboxAgent, 3, session); + await settle(); + }); + assert.equal(startCalls.at(-1).recovery, undefined); + assert.equal(startCalls.at(-1).mode, undefined); + assert.deepEqual(prompts.getRemoteSessionRecoveryPrompts(), []); + rendered.unmount(); +}); + test("wakes for different agents in one window are not collapsed", async () => { holdStarts = true; const { act, rendered } = await renderDetachedStart(); diff --git a/desktop/src/features/messages/ui/useDetachedAgentStart.ts b/desktop/src/features/messages/ui/useDetachedAgentStart.ts index 4794a458682..4750c3eaba5 100644 --- a/desktop/src/features/messages/ui/useDetachedAgentStart.ts +++ b/desktop/src/features/messages/ui/useDetachedAgentStart.ts @@ -8,7 +8,15 @@ import type { ManagedAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { getErrorMessage } from "./useMentionSendFlow.helpers"; import type { RemoteAgentSession } from "@/shared/api/remoteAgentSession"; -import { usesThreadSandbox } from "@/features/messages/lib/remoteAgentSession"; +import { + remoteSessionRecoveryKind, + remoteSessionErrorCode, + usesThreadSandbox, +} from "@/features/messages/lib/remoteAgentSession"; +import { + clearRemoteSessionRecoveryPrompt, + recordRemoteSessionRecoveryPrompt, +} from "@/features/messages/lib/remoteSessionRecoveryPrompts"; /** * Detached starts still in flight, keyed by the full tenant scope the wake @@ -203,7 +211,36 @@ export function useDetachedAgentStart(): ( replayFloorUnix: replayFloorUnix ?? Math.floor(Date.now() / 1000), sessionScope, }) + .then(() => { + if (sessionScope) { + clearRemoteSessionRecoveryPrompt({ + agentPubkey: agent.pubkey, + session: sessionScope, + expectedRelayUrl, + expectedSignerPubkey, + }); + } + }) .catch((error: unknown) => { + // A thread session that ended or wedged is never redeployed + // implicitly. Record the failure so the thread can offer the + // explicit end/recover actions; the panel fences it by tenant. + const message = getErrorMessage(error, ""); + const recoverable = + usesThreadSandbox(agent) && + sessionScope && + remoteSessionRecoveryKind(error) !== null; + if (recoverable) { + recordRemoteSessionRecoveryPrompt({ + agentPubkey: agent.pubkey, + agentName: agent.name, + session: sessionScope, + expectedRelayUrl, + expectedSignerPubkey, + error: message, + code: remoteSessionErrorCode(error), + }); + } // This settles arbitrarily long after the send, and `` // mounts outside the community remount boundary — so an unfenced // warning would render this community's agent name and error detail diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs index 200d788b540..f029f05bb28 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs +++ b/desktop/src/features/messages/ui/useMentionSendFlow.test-support.mjs @@ -8,6 +8,7 @@ import ts from "typescript"; import * as helpers from "./useMentionSendFlow.helpers.ts"; import * as draftStore from "../lib/useDrafts.ts"; import * as remoteAgentSession from "../lib/remoteAgentSession.ts"; +import * as remoteSessionRecoveryPrompts from "../lib/remoteSessionRecoveryPrompts.ts"; // Execute the product hooks with real React effects/renders; only external // query/mutation/media dependencies are mocked. Deferred promises isolate the @@ -92,6 +93,8 @@ export async function setup({ lifecycle = false } = {}) { react: React, "@/features/messages/lib/useDrafts": draftStore, "@/features/messages/lib/remoteAgentSession": remoteAgentSession, + "@/features/messages/lib/remoteSessionRecoveryPrompts": + remoteSessionRecoveryPrompts, sonner: { toast: { error: (error) => calls.push(["error", error]) } }, "@/features/agents/hooks": new Proxy( {},