diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index f5e922830..3d50231fa 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -80,6 +80,13 @@ archived workspace. History navigation must not infer workspace lifecycle from ` or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace. +History navigation preserves the selected agent as an explicit recovery target. If both that agent +and its workspace are archived, the workspace recovery action restores the workspace and unarchives +the selected agent as one user action. Other archived agents in the restored workspace remain +recoverable from History. Opening one pins its tab and renders the archived-agent callout before any +provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex +`thread/unarchive`) before the normal agent resume and timeline hydration flow. + ## Tabs vs archive These are two distinct concepts that used to be conflated: diff --git a/packages/app/e2e/archived-codex-agent.real.spec.ts b/packages/app/e2e/archived-codex-agent.real.spec.ts new file mode 100644 index 000000000..76b2b7ed6 --- /dev/null +++ b/packages/app/e2e/archived-codex-agent.real.spec.ts @@ -0,0 +1,115 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect } from "./fixtures"; +import { openSessions } from "./helpers/archive-tab"; +import { + assertChatTranscript, + cleanupRewindFlow, + launchAgent, + sendMessage, + type AgentHandle, +} from "./helpers/rewind-flow"; +import type { SeedDaemonClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; + +interface TimelineClient extends SeedDaemonClient { + fetchAgentTimeline( + agentId: string, + options: { direction: "tail"; projection: "projected"; limit: number }, + ): Promise; +} + +const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else."; +const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL"; +const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else."; +const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL"; + +async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise { + const history = await client.fetchAgentHistory({ page: { limit: 200 } }); + return history.entries.some((entry) => entry.agent.id === agentId); +} + +test.describe("archived Codex agent recovery", () => { + test.setTimeout(600_000); + + test("cold-opens without provider history, then unarchives and restores the conversation", async ({ + page, + }) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ + page, + provider: "codex", + cwd, + mode: "full-access", + }); + await sendMessage(handle, INITIAL_PROMPT); + await assertChatTranscript(handle, [ + { role: "user", text: INITIAL_PROMPT }, + { role: "assistant", text: INITIAL_REPLY }, + ]); + + await handle.client.archiveAgent(handle.agentId); + await expect + .poll( + async () => + (await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ?? + null, + { timeout: 30_000 }, + ) + .not.toBeNull(); + + const timelineClient = handle.client as TimelineClient; + await expect( + timelineClient.fetchAgentTimeline(handle.agentId, { + direction: "tail", + projection: "projected", + limit: 100, + }), + ).rejects.toThrow(/archiv/i); + await expect + .poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), { + timeout: 30_000, + }) + .toBe(true); + + await page.reload(); + await waitForSidebarHydration(page); + await openSessions(page); + await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click(); + + await expect( + page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("agent-load-error")).toHaveCount(0); + await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0); + await expect(page.getByTestId("user-message")).toHaveCount(0); + + await page.getByRole("button", { name: "Unarchive" }).click(); + await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, { + timeout: 60_000, + }); + await assertChatTranscript(handle, [ + { role: "user", text: INITIAL_PROMPT }, + { role: "assistant", text: INITIAL_REPLY }, + ]); + + await sendMessage(handle, FOLLOW_UP_PROMPT); + await assertChatTranscript(handle, [ + { role: "user", text: INITIAL_PROMPT }, + { role: "assistant", text: INITIAL_REPLY }, + { role: "user", text: FOLLOW_UP_PROMPT }, + { role: "assistant", text: FOLLOW_UP_REPLY }, + ]); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 2b243022f..d3fd79738 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -144,7 +144,7 @@ export interface SeedDaemonClient { } | null; fetchAgentHistory(options?: { page?: { limit: number }; - }): Promise<{ entries: Array<{ id: string }> }>; + }): Promise<{ entries: Array<{ agent: { id: string } }> }>; subscribeTerminal( terminalId: string, ): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>; diff --git a/packages/app/e2e/worktree-restore.spec.ts b/packages/app/e2e/worktree-restore.spec.ts index 480bdbad4..a3abdf9bf 100644 --- a/packages/app/e2e/worktree-restore.spec.ts +++ b/packages/app/e2e/worktree-restore.spec.ts @@ -44,7 +44,10 @@ test.describe("Worktree restore", () => { tempRepo = await createTempGitRepo("wt-restore-"); }); - async function createArchivedMissingWorktree(prefix: string) { + async function createArchivedMissingWorktree( + prefix: string, + options: { agentCount?: number; keepAgentsArchived?: boolean } = {}, + ) { const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); createdProjectIds.add(project.projectKey); const worktree = await createWorktreeViaDaemon(worktreeClient, { @@ -53,11 +56,19 @@ test.describe("Worktree restore", () => { }); createdProjectIds.add(worktree.projectKey); createdWorktreeDirectories.add(worktree.workspaceDirectory); - const agent = await createIdleAgent(client, { - cwd: worktree.workspaceDirectory, - workspaceId: worktree.workspaceId, - title: `${prefix}-${randomUUID().slice(0, 8)}`, - }); + const agents = await Promise.all( + Array.from({ length: options.agentCount ?? 1 }, () => + createIdleAgent(client, { + cwd: worktree.workspaceDirectory, + workspaceId: worktree.workspaceId, + title: `${prefix}-${randomUUID().slice(0, 8)}`, + }), + ), + ); + const agent = agents[0]; + if (!agent) { + throw new Error("Expected at least one archived-worktree agent"); + } await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory); await expect @@ -67,11 +78,21 @@ test.describe("Worktree restore", () => { // Match the remote cloud-race record: workspace archived and absent, while // the surviving closed agent record is not agent-archived. Refresh now owns // only agent lifecycle, so its expected cwd failure cannot recover the workspace. - await client.refreshAgent(agent.id).catch(() => undefined); - await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull(); + if (options.keepAgentsArchived) { + for (const archivedAgent of agents) { + await expect + .poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 }) + .not.toBeNull(); + } + } else { + await client.refreshAgent(agent.id).catch(() => undefined); + await expect + .poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }) + .toBeNull(); + } expect(existsSync(worktree.workspaceDirectory)).toBe(false); - return { agent, worktree }; + return { agent, agents, worktree }; } async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) { @@ -241,6 +262,54 @@ test.describe("Worktree restore", () => { ).toHaveText(switchedBranch, { timeout: 30_000 }); }); + test("recovers the selected agent with its workspace and later rescues another archived agent", async ({ + page, + }) => { + const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", { + agentCount: 2, + keepAgentsArchived: true, + }); + const [firstAgent, secondAgent] = agents; + if (!firstAgent || !secondAgent) { + throw new Error("Expected two archived agents"); + } + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openSessions(page); + await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click(); + + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await page.getByTestId("workspace-recovery-action").click(); + await expect + .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(true); + await expect( + page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect + .poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 }) + .toBeNull(); + await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, { + timeout: 30_000, + }); + + await openSessions(page); + await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click(); + await expect( + page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({ + timeout: 30_000, + }); + await page.getByRole("button", { name: "Unarchive" }).click(); + await expect + .poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 }) + .toBeNull(); + }); + test("restore failure stays visible and permits a successful retry", async ({ page }) => { const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry"); const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`; diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index d6f26bc8f..f51e1335b 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -110,7 +110,9 @@ function HostWorkspaceRouteContent() { const openValue = getParamValue(globalParams.open); const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); const workspaceExists = useWorkspaceExists(serverId, workspaceId); - const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent"; + const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]); + const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null; + const isAgentOpenIntent = recoveryAgentId !== null; const isOpenIntentWaitingForWorkspace = Boolean( isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists), ); @@ -147,7 +149,6 @@ function HostWorkspaceRouteContent() { } consumedIntentRef.current = consumptionKey; - const openIntent = parseWorkspaceOpenIntent(openValue); if (openIntent) { prepareWorkspaceTab({ serverId, @@ -171,6 +172,7 @@ function HostWorkspaceRouteContent() { hasHydratedWorkspaceLayoutStore, isOpenIntentWaitingForWorkspace, navigation, + openIntent, openValue, rootNavigationState?.key, serverId, @@ -185,10 +187,16 @@ function HostWorkspaceRouteContent() { return null; } - return ; + return ; } -function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { +function WorkspaceDeck({ + recoveryRequested, + recoveryAgentId, +}: { + recoveryRequested: boolean; + recoveryAgentId: string | null; +}) { const activeSelection = useActiveWorkspaceSelection(); const [mountedSelections, setMountedSelections] = useState(() => activeSelection ? [activeSelection] : [], @@ -234,6 +242,7 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { selection={selection} activeSelection={activeSelection} recoveryRequested={recoveryRequested} + recoveryAgentId={recoveryAgentId} onUnmountInactive={unmountWorkspaceSelection} /> ); @@ -246,11 +255,13 @@ function WorkspaceDeckEntry({ selection, activeSelection, recoveryRequested, + recoveryAgentId, onUnmountInactive, }: { selection: ActiveWorkspaceSelection; activeSelection: ActiveWorkspaceSelection; recoveryRequested: boolean; + recoveryAgentId: string | null; onUnmountInactive: (selection: ActiveWorkspaceSelection) => void; }) { const isActive = areWorkspaceSelectionsEqual(selection, activeSelection); @@ -282,6 +293,7 @@ function WorkspaceDeckEntry({ workspaceId={selection.workspaceId} isRouteFocused={isActive} recoveryRequested={isActive && recoveryRequested} + recoveryAgentId={isActive ? recoveryAgentId : null} /> ); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 3746c4e63..980fd7848 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -394,6 +394,7 @@ export function AgentList({ serverId, agentId, workspaceId: agent.workspaceId, + pin: true, }); }, [isActionSheetVisible, onAgentSelect], diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index 4bfb5d30b..fb04bab62 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -9,6 +9,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { Button } from "@/components/ui/button"; import type { Theme } from "@/styles/theme"; +import { toErrorMessage } from "@/utils/error-messages"; interface ArchivedAgentCalloutProps { serverId: string; @@ -21,6 +22,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const [isUnarchiving, setIsUnarchiving] = useState(false); + const [unarchiveError, setUnarchiveError] = useState(null); const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" }); @@ -32,10 +34,11 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout const handleUnarchive = useCallback(async () => { if (!client || !isConnected || isUnarchiving) return; setIsUnarchiving(true); + setUnarchiveError(null); try { await client.refreshAgent(agentId); } catch (error) { - console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error); + setUnarchiveError(toErrorMessage(error)); setIsUnarchiving(false); } }, [client, isConnected, isUnarchiving, agentId]); @@ -44,16 +47,23 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout - - {t("agentPanel.archived.callout")} - + + + {t("agentPanel.archived.callout")} + + + {unarchiveError ? ( + + {unarchiveError} + + ) : null} @@ -97,8 +107,16 @@ const styles = StyleSheet.create((theme: Theme) => ({ md: theme.spacing[6], }, }, + calloutStack: { + gap: theme.spacing[2], + }, calloutText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, + errorText: { + color: theme.colors.statusDanger, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, })) as unknown as Record; diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 5112cce4e..141132942 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -57,6 +57,7 @@ function createAgentWithStatus({ id, status }: { id: string; status: Agent["stat function createBaseInput(): AgentScreenMachineInput { return { agent: null, + isArchived: false, continuity: { kind: "none" }, missingAgentState: { kind: "idle" }, isConnected: true, @@ -385,6 +386,22 @@ describe("deriveAgentScreenViewState", () => { expect(result.memory.lastReadyAgent).toBeNull(); }); + it("renders an archived agent before provider history is initialized", () => { + const result = deriveAgentScreenViewState({ + input: { + ...createBaseInput(), + agent: createAgent("agent-1"), + isArchived: true, + needsAuthoritativeSync: true, + }, + memory: createBaseMemory(), + }); + + const ready = expectReadyState(result.state); + expect(ready.agent.id).toBe("agent-1"); + expect(ready.sync).toEqual({ status: "idle" }); + }); + it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => { const memory = createBaseMemory(); const input: AgentScreenMachineInput = { diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index b87189b39..1b8dca725 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -41,6 +41,7 @@ export type AgentScreenMissingState = export interface AgentScreenMachineInput { agent: AgentScreenAgent | null; + isArchived: boolean; missingAgentState: AgentScreenMissingState; isConnected: boolean; isArchivingCurrentAgent: boolean; @@ -61,6 +62,7 @@ function hasOptimisticCreateContinuity(input: AgentScreenMachineInput): boolean function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInput): boolean { return ( + !input.isArchived && !hasOptimisticCreateContinuity(input) && !input.hasHydratedHistoryBefore && (input.needsAuthoritativeSync || input.isHistorySyncing) @@ -164,6 +166,9 @@ function resolveAgentScreenSync(args: { hadInitialSyncFailure: boolean; }): AgentScreenReadySyncState { const { input, hadInitialSyncFailure } = args; + if (input.isArchived) { + return { status: "idle" }; + } if (!input.isConnected) { return { status: "reconnecting" }; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index e2d915ecc..ed3a1689a 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -341,13 +341,14 @@ function useAgentPanelDescriptor( } function AgentPanel() { - const { serverId, target, openFileInWorkspace } = usePaneContext(); + const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isInteractive } = usePaneFocus(); invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( void; @@ -535,6 +538,7 @@ function AgentPanelContent({ return ( ; @@ -585,6 +591,8 @@ function AgentPanelBody({ ); const [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); + const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent); useEffect(() => { lookupAttemptTokenRef.current += 1; @@ -596,6 +604,9 @@ function AgentPanelBody({ return; } if (agentState.id) { + if (workspaceKey) { + resolvePendingAgent(workspaceKey, agentId); + } if (lookupState.tag !== "idle") { setLookupState({ tag: "idle" }); } @@ -618,6 +629,9 @@ function AgentPanelBody({ return; } if (!result) { + if (workspaceKey) { + resolvePendingAgent(workspaceKey, agentId); + } setLookupState({ tag: "not_found", message: `Agent not found: ${agentId}`, @@ -626,6 +640,9 @@ function AgentPanelBody({ } storeFetchedAgentDetail({ serverId, result }); + if (workspaceKey) { + resolvePendingAgent(workspaceKey, agentId); + } setLookupState({ tag: "idle" }); return; }) @@ -635,12 +652,25 @@ function AgentPanelBody({ } const message = toErrorMessage(error); if (isNotFoundErrorMessage(message)) { + if (workspaceKey) { + resolvePendingAgent(workspaceKey, agentId); + } setLookupState({ tag: "not_found", message }); return; } setLookupState({ tag: "error", message }); }); - }, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]); + }, [ + agentId, + agentState.id, + client, + hasSession, + isConnected, + lookupState.tag, + resolvePendingAgent, + serverId, + workspaceKey, + ]); if (lookupState.tag === "not_found") { return ( @@ -894,6 +924,7 @@ function ChatAgentContent({ routeKey: `${serverId}:${agentId ?? ""}`, input: { agent: agent ?? null, + isArchived: agentState.archivedAt !== null, missingAgentState, isConnected, isArchivingCurrentAgent, @@ -947,6 +978,9 @@ function ChatAgentContent({ if (!agentId) { return; } + if (agentState.archivedAt) { + return; + } if (agentState.id && hasAppliedAuthoritativeHistory) { if ( missingAgentState.kind === "resolving" || @@ -1012,6 +1046,7 @@ function ChatAgentContent({ }); }, [ agentState.id, + agentState.archivedAt, hasAppliedAuthoritativeHistory, agentId, client, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 65f4333af..f41bde08a 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -291,11 +291,13 @@ interface WorkspaceScreenProps { workspaceId: string; isRouteFocused?: boolean; recoveryRequested?: boolean; + recoveryAgentId?: string | null; } type WorkspaceScreenContentProps = WorkspaceScreenProps & { isRouteFocused: boolean; recoveryRequested: boolean; + recoveryAgentId: string | null; }; function trimNonEmpty(value: string | null | undefined): string | null { @@ -912,6 +914,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId, isRouteFocused, recoveryRequested, + recoveryAgentId, }: WorkspaceScreenProps) { const navigationFocused = useIsFocused(); return ( @@ -920,6 +923,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId={workspaceId} isRouteFocused={isRouteFocused ?? navigationFocused} recoveryRequested={recoveryRequested ?? false} + recoveryAgentId={recoveryAgentId ?? null} /> ); }); @@ -1715,6 +1719,7 @@ function WorkspaceScreenContent({ workspaceId, isRouteFocused, recoveryRequested, + recoveryAgentId, }: WorkspaceScreenContentProps) { const { t } = useTranslation(); const _insets = useSafeAreaInsets(); @@ -1791,6 +1796,7 @@ function WorkspaceScreenContent({ const workspaceRecovery = useWorkspaceRecovery({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, + agentId: recoveryAgentId, enabled: shouldInspectWorkspaceRecovery( hasHydratedWorkspaces, workspaceDescriptor, diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 3b06aa9c8..f7633b34c 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -194,6 +194,7 @@ interface ReorderPaneTabsInLayoutInput { export interface WorkspaceTabReconcileState { layout: WorkspaceLayout; pinnedAgentIds?: ReadonlySet | null; + pendingAgentIds?: ReadonlySet | null; hiddenAgentIds?: ReadonlySet | null; } @@ -1653,13 +1654,14 @@ interface EntityTabGroup { function applyPinnedAndHidden(input: { baseAgentIds: Set; pinnedAgentIds: Set; + pendingAgentIds: Set; hiddenAgentIds: Set; knownAgentIds: Set; }): Set { - const { baseAgentIds, pinnedAgentIds, hiddenAgentIds, knownAgentIds } = input; + const { baseAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds } = input; const result = new Set(baseAgentIds); for (const agentId of pinnedAgentIds) { - if (knownAgentIds.has(agentId)) { + if (knownAgentIds.has(agentId) || pendingAgentIds.has(agentId)) { result.add(agentId); } } @@ -1784,6 +1786,7 @@ export function reconcileWorkspaceTabs( findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null; let reconciledFocusedTabId = originalFocusedTabId; const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []); + const pendingAgentIds = new Set(state.pendingAgentIds ?? []); const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); @@ -1795,12 +1798,14 @@ export function reconcileWorkspaceTabs( const visibleAgentIds = applyPinnedAndHidden({ baseAgentIds: activeAgentIds, pinnedAgentIds, + pendingAgentIds, hiddenAgentIds, knownAgentIds, }); const autoOpenSet = applyPinnedAndHidden({ baseAgentIds: autoOpenAgentIds, pinnedAgentIds, + pendingAgentIds, hiddenAgentIds, knownAgentIds, }); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 546016b26..b65eedd2e 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -1591,6 +1591,66 @@ describe("workspace-layout-store actions", () => { expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); }); + it("keeps an explicitly pinned archived agent before its detail is hydrated", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + + store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" }); + store.pinAgent(workspaceKey, "archived-agent"); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + knownAgentIds: [], + standaloneTerminalIds: [], + }); + + expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ + "agent_archived-agent", + ]); + + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + knownAgentIds: [], + standaloneTerminalIds: [], + }); + + expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ + "agent_archived-agent", + ]); + + store.resolvePendingAgent(workspaceKey, "archived-agent"); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + knownAgentIds: [], + standaloneTerminalIds: [], + }); + + expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([]); + + store.openTabFocused(workspaceKey, { kind: "agent", agentId: "archived-agent" }); + store.pinAgent(workspaceKey, "archived-agent"); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + knownAgentIds: [], + standaloneTerminalIds: [], + }); + + expect(store.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ + "agent_archived-agent", + ]); + }); + it("retargeting a tab to an agent clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 896be66a6..2f84c53b8 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -75,6 +75,7 @@ interface WorkspaceLayoutStore { layoutByWorkspace: Record; splitSizesByWorkspace: Record>; pinnedAgentIdsByWorkspace: Record>; + pendingAgentIdsByWorkspace: Record>; hiddenAgentIdsByWorkspace: Record>; focusRestorationByWorkspace: Record; openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; @@ -89,6 +90,7 @@ interface WorkspaceLayoutStore { retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null; convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null; reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void; + resolvePendingAgent: (workspaceKey: string, agentId: string) => void; reorderTabs: (workspaceKey: string, tabIds: string[]) => void; getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[]; splitPane: ( @@ -229,6 +231,7 @@ export function createWorkspaceLayoutStore( layoutByWorkspace: {}, splitSizesByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, + pendingAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, openTabFocused: (workspaceKey, target) => { @@ -467,6 +470,7 @@ export function createWorkspaceLayoutStore( { layout: currentLayout, pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, + pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, }, snapshot, @@ -483,6 +487,25 @@ export function createWorkspaceLayoutStore( }; }); }, + resolvePendingAgent: (workspaceKey, agentId) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedWorkspaceKey || !normalizedAgentId) { + return; + } + + set((state) => { + const pendingAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( + state.pendingAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ); + if (pendingAgentIdsByWorkspace === state.pendingAgentIdsByWorkspace) { + return state; + } + return { pendingAgentIdsByWorkspace }; + }); + }, reorderTabs: (workspaceKey, tabIds) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -762,7 +785,12 @@ export function createWorkspaceLayoutStore( set((state) => { const currentPinnedAgentIds = state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; - if (currentPinnedAgentIds?.has(normalizedAgentId)) { + const currentPendingAgentIds = + state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; + if ( + currentPinnedAgentIds?.has(normalizedAgentId) && + currentPendingAgentIds?.has(normalizedAgentId) + ) { return state; } @@ -779,6 +807,11 @@ export function createWorkspaceLayoutStore( ...state.pinnedAgentIdsByWorkspace, [normalizedWorkspaceKey]: nextPinnedAgentIds, }, + pendingAgentIdsByWorkspace: addAgentIdToWorkspaceSet( + state.pendingAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ), }; }); }, @@ -803,6 +836,11 @@ export function createWorkspaceLayoutStore( delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey]; return { pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace, + pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( + state.pendingAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ), }; } @@ -814,6 +852,11 @@ export function createWorkspaceLayoutStore( ...state.pinnedAgentIdsByWorkspace, [normalizedWorkspaceKey]: nextPinnedAgentIds, }, + pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( + state.pendingAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedAgentId, + ), }; }); }, @@ -872,6 +915,7 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.layoutByWorkspace || normalizedWorkspaceKey in state.splitSizesByWorkspace || normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || + normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace || normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || normalizedWorkspaceKey in state.focusRestorationByWorkspace; if (!hasAny) { @@ -883,6 +927,8 @@ export function createWorkspaceLayoutStore( state.splitSizesByWorkspace; const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } = state.pinnedAgentIdsByWorkspace; + const { [normalizedWorkspaceKey]: _pending, ...pendingAgentIdsByWorkspace } = + state.pendingAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } = state.hiddenAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = @@ -891,6 +937,7 @@ export function createWorkspaceLayoutStore( layoutByWorkspace, splitSizesByWorkspace, pinnedAgentIdsByWorkspace, + pendingAgentIdsByWorkspace, hiddenAgentIdsByWorkspace, focusRestorationByWorkspace, }; diff --git a/packages/app/src/workspace-recovery/model.test.ts b/packages/app/src/workspace-recovery/model.test.ts index c4a6de7a2..46f7aba86 100644 --- a/packages/app/src/workspace-recovery/model.test.ts +++ b/packages/app/src/workspace-recovery/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveWorkspaceRecoveryModel } from "./model"; +import { recoverWorkspaceSelection, resolveWorkspaceRecoveryModel } from "./model"; describe("resolveWorkspaceRecoveryModel", () => { it("keeps newer recovery actions visible but non-actionable", () => { @@ -29,3 +29,24 @@ describe("resolveWorkspaceRecoveryModel", () => { }); }); }); + +describe("recoverWorkspaceSelection", () => { + it("restores the workspace and selected archived agent as one recovery action", async () => { + const operations: string[] = []; + + await recoverWorkspaceSelection({ + workspaceId: "workspace-1", + agentId: "agent-1", + client: { + restoreWorkspace: async (workspaceId) => { + operations.push(`workspace:${workspaceId}`); + }, + refreshAgent: async (agentId) => { + operations.push(`agent:${agentId}`); + }, + }, + }); + + expect(operations).toEqual(["workspace:workspace-1", "agent:agent-1"]); + }); +}); diff --git a/packages/app/src/workspace-recovery/model.ts b/packages/app/src/workspace-recovery/model.ts index 5d5ebf15e..2c85a06f4 100644 --- a/packages/app/src/workspace-recovery/model.ts +++ b/packages/app/src/workspace-recovery/model.ts @@ -34,6 +34,22 @@ export interface WorkspaceRecoveryController { retryInspection: () => void; } +export interface WorkspaceSelectionRecoveryClient { + restoreWorkspace: (workspaceId: string) => Promise; + refreshAgent: (agentId: string) => Promise; +} + +export async function recoverWorkspaceSelection(input: { + client: WorkspaceSelectionRecoveryClient; + workspaceId: string; + agentId?: string | null; +}): Promise { + await input.client.restoreWorkspace(input.workspaceId); + if (input.agentId) { + await input.client.refreshAgent(input.agentId); + } +} + function resolveRecoveryPhase(input: { pending: boolean; error: string | null; diff --git a/packages/app/src/workspace-recovery/use-workspace-recovery.ts b/packages/app/src/workspace-recovery/use-workspace-recovery.ts index d91aa3ce6..ab1995719 100644 --- a/packages/app/src/workspace-recovery/use-workspace-recovery.ts +++ b/packages/app/src/workspace-recovery/use-workspace-recovery.ts @@ -4,7 +4,11 @@ import { useFetchQuery } from "@/data/query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { toErrorMessage } from "@/utils/error-messages"; -import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model"; +import { + recoverWorkspaceSelection, + resolveWorkspaceRecoveryModel, + type WorkspaceRecoveryController, +} from "./model"; export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model"; @@ -28,6 +32,7 @@ function waitForMinimumRecoveryLoadingTime(): Promise { export function useWorkspaceRecovery(input: { serverId: string; workspaceId: string; + agentId?: string | null; enabled: boolean; }): WorkspaceRecoveryController { const client = useHostRuntimeClient(input.serverId); @@ -56,7 +61,11 @@ export function useWorkspaceRecovery(input: { } await waitForRecoveryLoadingPresentation(); await waitForMinimumRecoveryLoadingTime(); - await client.restoreWorkspace(input.workspaceId); + await recoverWorkspaceSelection({ + client, + workspaceId: input.workspaceId, + agentId: input.agentId, + }); }, }); diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index e0e806b01..6f54864b3 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -11,7 +11,12 @@ import { toAgentPersistenceHandle, } from "../persistence-hooks.js"; -const pendingAgentInitializations = new Map>(); +interface PendingAgentInitialization { + promise: Promise; + options: { broadcastTimeline: boolean }; +} + +const pendingAgentInitializations = new Map(); export type AgentLoaderManager = Pick< AgentManager, @@ -27,6 +32,7 @@ export interface EnsureAgentLoadedDeps { agentManager: AgentLoaderManager; agentStorage: AgentStorage; validProviders?: Iterable; + broadcastTimeline?: boolean; logger: Logger; } @@ -58,6 +64,13 @@ export async function ensureAgentLoaded( deps: EnsureAgentLoadedDeps, ): Promise { await deps.agentManager.waitForAgentClose?.(agentId); + + const inflight = pendingAgentInitializations.get(agentId); + if (inflight) { + inflight.options.broadcastTimeline ||= deps.broadcastTimeline === true; + return inflight.promise; + } + const existing = deps.agentManager.touchAgentActivity?.(agentId) ?? deps.agentManager.getAgent(agentId); if (existing) { @@ -69,11 +82,15 @@ export async function ensureAgentLoaded( // before storage-backed resume begins. await deps.agentManager.waitForAgentClose?.(agentId); - const inflight = pendingAgentInitializations.get(agentId); - if (inflight) { - return inflight; + const laterInflight = pendingAgentInitializations.get(agentId); + if (laterInflight) { + laterInflight.options.broadcastTimeline ||= deps.broadcastTimeline === true; + return laterInflight.promise; } + const pendingOptions = { + broadcastTimeline: deps.broadcastTimeline === true, + }; const initPromise = (async () => { const record = await deps.agentStorage.get(agentId); if (!record) { @@ -111,17 +128,20 @@ export async function ensureAgentLoaded( deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); } - await deps.agentManager.hydrateTimelineFromProvider(agentId); + await deps.agentManager.hydrateTimelineFromProvider(agentId, { + broadcast: () => pendingOptions.broadcastTimeline, + }); return deps.agentManager.getAgent(agentId) ?? snapshot; })(); - pendingAgentInitializations.set(agentId, initPromise); + const pending: PendingAgentInitialization = { promise: initPromise, options: pendingOptions }; + pendingAgentInitializations.set(agentId, pending); try { return await initPromise; } finally { const current = pendingAgentInitializations.get(agentId); - if (current === initPromise) { + if (current === pending) { pendingAgentInitializations.delete(agentId); } } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 74d3a32f3..fa1d77f5c 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -7518,6 +7518,76 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share } }); +test("a shared agent load upgrades provider history hydration to broadcast", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-shared-load-broadcast-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const historyStarted = deferred(); + const historyAllowed = deferred(); + const client = new (class extends TestAgentClient { + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new (class extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + historyStarted.resolve(); + await historyAllowed.promise; + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "Recovered history" }, + }; + } + })({ provider: "codex", cwd: config?.cwd ?? workdir }); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await manager.collectIdleAgents({ + cutoff: new Date(Date.now() + 1_000), + protectedAgentIds: new Set(), + }); + await manager.deleteAgentState(agent.id); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { agentId: agent.id, replayState: false }); + + const quietLoad = ensureAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + await historyStarted.promise; + const broadcastingLoad = ensureAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + broadcastTimeline: true, + logger, + }); + historyAllowed.resolve(); + await Promise.all([quietLoad, broadcastingLoad]); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "agent_stream", + agentId: agent.id, + event: expect.objectContaining({ + type: "timeline", + item: { type: "assistant_message", text: "Recovered history" }, + }), + }), + ); + } finally { + historyAllowed.resolve(); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("collectIdleAgents leaves recent, protected, internal, running, and error agents resident", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-eligibility-")); const client = new (class extends TestAgentClient { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 32931c097..cfda95c1b 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -185,7 +185,7 @@ export interface SubscribeOptions { interface HydrateTimelineOptions { force?: boolean; - broadcast?: boolean; + broadcast?: boolean | (() => boolean); } export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & { @@ -3177,12 +3177,17 @@ export class AgentManager { return; } + const broadcast = options?.broadcast ?? false; + if (options?.force) { - await this.forceHydrateTimelineFromLegacyProviderHistory(agent, options.broadcast === true); + await this.forceHydrateTimelineFromLegacyProviderHistory( + agent, + typeof broadcast === "function" ? broadcast() : broadcast, + ); return; } - await this.primeTimelineFromLegacyProviderHistory(agent, options?.broadcast === true); + await this.primeTimelineFromLegacyProviderHistory(agent, broadcast); } private async forceHydrateTimelineFromLegacyProviderHistory( @@ -3239,15 +3244,24 @@ export class AgentManager { private async primeTimelineFromLegacyProviderHistory( agent: ActiveManagedAgent, - broadcast: boolean, + broadcast: boolean | (() => boolean), ): Promise { + const deferredBroadcast = typeof broadcast === "function"; + const timelineEvents: Array<{ + event: Extract; + row: AgentTimelineRow; + }> = []; + const providerSubagentEvents: AgentManagerEvent[] = []; agent.historyPrimed = true; try { for await (const event of agent.session.streamHistory()) { if (event.type === "provider_subagent") { const update = this.providerSubagents.apply(agent.id, event.provider, event.event); - if (broadcast) { - this.dispatch({ type: "provider_subagent", event: update }); + const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update }; + if (deferredBroadcast) { + providerSubagentEvents.push(managerEvent); + } else if (broadcast) { + this.dispatch(managerEvent); } continue; } @@ -3257,15 +3271,38 @@ export class AgentManager { if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { continue; } - this.recordTimeline( + const row = this.recordTimeline( agent.id, event.item, event.timestamp ? { timestamp: event.timestamp } : undefined, ); + if (deferredBroadcast) { + timelineEvents.push({ event, row }); + } else if (broadcast) { + this.dispatchStream(agent.id, event, { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }); + } } } catch { // ignore history failures } + + if (typeof broadcast !== "function" || !broadcast()) { + return; + } + for (const event of providerSubagentEvents) { + this.dispatch(event); + } + for (const { event, row } of timelineEvents) { + this.dispatchStream(agent.id, event, { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }); + } } private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 532389815..84c9df422 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -5632,6 +5632,7 @@ describe("agent snapshot MCP serialization", () => { expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled(); expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith( "archived-activity-agent", + { broadcast: expect.any(Function) }, ); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0ee412286..2192ad69b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -30,12 +30,7 @@ import { CursorError } from "./pagination/cursor.js"; import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js"; -import { - buildConfigOverrides, - extractTimestamps, - isStoredAgentProviderAvailable, - toAgentPersistenceHandle, -} from "./persistence-hooks.js"; +import { isStoredAgentProviderAvailable, toAgentPersistenceHandle } from "./persistence-hooks.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js"; import { formatSystemNotificationPrompt, @@ -3183,16 +3178,18 @@ export class Session { if (!isStoredAgentProviderAvailable(record, registeredProviderIds)) { throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); } - const handle = toAgentPersistenceHandle(registeredProviderIds, record.persistence); - if (!handle) { + if (!toAgentPersistenceHandle(registeredProviderIds, record.persistence)) { throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`); } - snapshot = await this.agentManager.resumeAgentFromPersistence( - handle, - buildConfigOverrides(record), - agentId, - extractTimestamps(record), - ); + // Share the loader's per-agent in-flight operation with timeline fetches. + // Unarchiving publishes the record before provider resume finishes, so + // the agent pane can otherwise race this request and resume it twice. + snapshot = await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + broadcastTimeline: true, + logger: this.sessionLogger, + }); } await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true }); await this.agentUpdates.forwardLiveAgent(snapshot);