From 42ee5a5949d81993ecc5f83123d864f9f1cd228b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Jul 2026 15:03:09 +0200 Subject: [PATCH] Restore archived agents from History (#2316) * fix(app): restore archived agents from History History navigation lost the explicit archived-agent intent during tab reconciliation, and workspace recovery stopped after restoring the workspace. Preserve the selected tab and recover its provider session as one action while serializing provider resume with timeline hydration. * fix(app): preserve archived agent recovery invariants * fix(server): preserve timeline hydration call shape * fix(app): retain agent tabs through lookup * fix(server): upgrade in-flight timeline broadcast --- docs/agent-lifecycle.md | 7 ++ .../app/e2e/archived-codex-agent.real.spec.ts | 115 ++++++++++++++++++ packages/app/e2e/helpers/seed-client.ts | 2 +- packages/app/e2e/worktree-restore.spec.ts | 87 +++++++++++-- .../workspace/[workspaceId]/index.tsx | 20 ++- packages/app/src/components/agent-list.tsx | 1 + .../src/components/archived-agent-callout.tsx | 40 ++++-- .../use-agent-screen-state-machine.test.ts | 17 +++ .../hooks/use-agent-screen-state-machine.ts | 5 + packages/app/src/panels/agent-panel.tsx | 39 +++++- .../screens/workspace/workspace-screen.tsx | 6 + .../src/stores/workspace-layout-actions.ts | 9 +- .../src/stores/workspace-layout-store.test.ts | 60 +++++++++ .../app/src/stores/workspace-layout-store.ts | 49 +++++++- .../app/src/workspace-recovery/model.test.ts | 23 +++- packages/app/src/workspace-recovery/model.ts | 16 +++ .../use-workspace-recovery.ts | 13 +- .../server/src/server/agent/agent-loading.ts | 34 ++++-- .../src/server/agent/agent-manager.test.ts | 70 +++++++++++ .../server/src/server/agent/agent-manager.ts | 51 ++++++-- .../src/server/agent/mcp-server.test.ts | 1 + packages/server/src/server/session.ts | 25 ++-- 22 files changed, 629 insertions(+), 61 deletions(-) create mode 100644 packages/app/e2e/archived-codex-agent.real.spec.ts 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);