fix(agents): restore archived Codex workspaces without losing focus (#4736)

* fix(agents): restore archived Codex workspaces without losing focus

History selection must survive without cached agent records, and reading an archived Codex thread must not acquire an interactive writer. Keep workspace restore explicit, serialize native lifecycle transitions, and leave transcript lifetime with its owner.

* test(app): express Codex restore specs as user journeys

* test(app): finish restore fixture cleanup after teardown errors

* test(app): align restore coverage and await fixture readiness

Workspace restore leaves agents archived until explicit Unarchive. Keep that distinction covered for multiple agents. Compile the recovery fixture before its interaction test starts, and wait for sidebar agent hydration before asserting tab presence.
This commit is contained in:
Mohamed Boudra
2026-09-12 08:47:44 +02:00
committed by GitHub
parent fa93c4290e
commit a7ce602c83
28 changed files with 945 additions and 398 deletions
+22 -10
View File
@@ -109,16 +109,28 @@ 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. Authoritative
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
resume repairs that drift through the provider's native unarchive hook; history resume does not.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
history hydration. Other archived agents stay archived.
Opening an agent is a navigation choice, independent of whether its details are cached. The
layout retains that choice across reload while the panel fetches the agent from the daemon.
Once the daemon reports the agent active, its tab follows normal archive propagation again.
An empty active list cannot cancel an explicit History selection. Agent-detail loading does
not own selection or release the explicit open.
Persisted resume, native restore, and both live and stored-only archive enter the same per-agent
lifecycle queue. Resume chooses its history or interactive purpose from the durable record after
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
close retains the runtime for cleanup and blocks replacement through that close operation.
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
releases the process before returning. It never loads, resumes, or unarchives a native thread,
including legacy records whose native archive failed. The retained history session contains only
the read results. Interactive resume remains responsible for repairing a provider session archived
outside Paseo while its Paseo agent is active.
Provider session connection owns every process it spawns until the session is registered with
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
+5
View File
@@ -95,6 +95,11 @@ owner wraps cached preparation, network catch-up, accepted timeline application,
behind one interface. React supplies transport and projection operations without selecting a cache
path or issuing a separate persistence notification.
Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh
history response can arrive before the active-list response that omits it. Clearing history there
would discard accepted rows while the viewed owner still considers them synchronized. Entity
deletion clears the transcript; list membership changes do not.
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
session and host-scoped setup state together.
@@ -4,9 +4,15 @@ import { expect, test, type Page, type TestInfo } from "@playwright/test";
let server: Server;
let appUrl: string;
test.beforeAll(async () => {
// Compile this separate fixture entrypoint in setup, like the main app warmup.
// Cold Metro compilation must not consume the recovery interaction's timeout.
test.setTimeout(120_000);
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) throw new Error("E2E_METRO_PORT is required");
const bundle = `http://localhost:${metroPort}/packages/app/e2e/support/recovery-app.bundle?platform=web&dev=true&minify=false&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=src%2Fapp`;
const compiled = await fetch(bundle, { signal: AbortSignal.timeout(120_000) });
if (!compiled.ok) throw new Error(`Recovery fixture compilation failed: HTTP ${compiled.status}`);
await compiled.arrayBuffer();
server = createServer((_request, response) => {
response.setHeader("Content-Type", "text/html");
response.end(
@@ -7,7 +7,6 @@ import { getServerId } from "../support/helpers/server-id";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
// Model B sidebar shape: every project — git or non-git, single- or
// multi-workspace — renders as the same expandable parent, the deepest sidebar
@@ -96,8 +95,9 @@ test.describe("Model B sidebar shape", () => {
try {
// Open the workspace and materialize both an agent tab and a terminal tab.
await gotoWorkspace(page, mock.workspaceId);
const agentTabs = await getVisibleWorkspaceAgentTabIds(page);
expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`);
await expect(
page.getByTestId(`workspace-tab-agent_${mock.agentId}`).filter({ visible: true }),
).toBeVisible();
await clickNewTerminal(page);
await expect(
@@ -0,0 +1,63 @@
import { test } from "../support/helpers/codex-workspace-restore";
test.use({
e2eDaemonConfig: { version: 1, daemon: { mcp: { enabled: true } } },
trace: "on",
video: "on",
});
test.describe.configure({ timeout: 600_000 });
test("a fresh client restores an archived Codex agent across a reload without agent cache", async ({
codexRestore,
}) => {
await test.step("Create, finish, and archive Codex before this browser connects", async () => {
await codexRestore.createWorktreeWithCodex();
await codexRestore.archiveBeforeBrowserConnects();
});
await test.step("Connect the fresh browser, open History, and restore the workspace", async () => {
await codexRestore.connectFreshBrowser();
await codexRestore.openArchivedWorkspaceFromHistory();
await codexRestore.restoreWorkspace();
await codexRestore.expectArchivedAgentSelectedWithHistory();
});
await test.step("Reload without cached agent data and keep the selected archived agent", async () => {
await codexRestore.reloadWithoutAgentCache();
await codexRestore.waitForWorkspaceHydration();
await codexRestore.expectArchivedAgentSelectedWithHistory();
});
await test.step("Unarchive the agent and keep its idle composer after another cache-empty reload", async () => {
await codexRestore.unarchiveAgent();
await codexRestore.reloadWithoutAgentCache();
await codexRestore.expectIdleAgentWithVisibleComposer();
});
});
test("restore an archived worktree, then unarchive its completed Codex agent", async ({
codexRestore,
}) => {
await test.step("1. Create a managed worktree with a real Codex agent through Paseo MCP", async () => {
await codexRestore.createWorktreeWithCodex();
});
await test.step("2. Wait for Codex to finish and show its reply", async () => {
await codexRestore.waitForCompletedReply();
});
await test.step("3. Archive the workspace from its sidebar menu", async () => {
await codexRestore.archiveWorkspaceFromSidebar();
});
await test.step("4. Assert workspace and agent are archived and the worktree is removed", async () => {
await codexRestore.expectWorkspaceAndAgentArchived();
});
await test.step("5. Open the archived workspace from History", async () => {
await codexRestore.openArchivedWorkspaceFromHistory();
});
await test.step("6. Restore the workspace", async () => {
await codexRestore.restoreWorkspace();
await codexRestore.expectWorktreeRestoredWithArchivedAgentSelected();
});
await test.step("7. Click Unarchive on the agent", async () => {
await codexRestore.unarchiveAgent();
});
await test.step("8. Expect an idle agent with a visible editable composer", async () => {
await codexRestore.expectIdleAgentWithVisibleComposer(60_000);
});
});
@@ -265,7 +265,7 @@ test.describe("Worktree restore", () => {
).toHaveText(switchedBranch, { timeout: 30_000 });
});
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
test("restores the workspace before explicitly unarchiving each selected agent", async ({
page,
}) => {
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
@@ -292,9 +292,16 @@ test.describe("Worktree restore", () => {
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
).toHaveAttribute("aria-selected", "true");
expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
.toBeNull();
expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull();
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
timeout: 30_000,
});
@@ -0,0 +1,277 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
import type { TestInfo } from "@playwright/test";
import { test as base, expect, type Page } from "../fixtures";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { openSessions } from "./archive-tab";
import { getE2EDaemonPort } from "./daemon-port";
import { assertComposerIdle } from "./rewind-flow";
import { getServerId } from "./server-id";
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./sidebar";
import { createTempGitRepo } from "./workspace";
import { waitForSidebarHydration } from "./workspace-ui";
import type { SeedDaemonClient } from "./seed-client";
const REPLY = "CODEX_WORKTREE_RESTORE_READY";
const workspaceSchema = z.object({
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
isolation: z.literal("worktree"),
});
async function callPaseoTool(
mcp: Client,
name: string,
args: Record<string, unknown>,
): Promise<unknown> {
const result = await mcp.callTool({ name, arguments: args }, undefined, { timeout: 240_000 });
expect(result).not.toMatchObject({ isError: true });
return result.structuredContent;
}
async function defaultCodexModel(mcp: Client): Promise<string> {
const result = z
.object({ models: z.array(z.object({ id: z.string(), isDefault: z.boolean() })) })
.parse(await callPaseoTool(mcp, "list_models", { provider: "codex" }));
const model = result.models.find((entry) => entry.isDefault);
if (!model) throw new Error("Codex did not advertise a default model");
return model.id;
}
function recordTimelineWire(page: Page): string[] {
const wire: string[] = [];
page.on("websocket", (socket) => {
const recordFrame = (direction: string, message: string) => {
if (/agent.*timeline|fetch_agents|agent_update/.test(message)) {
wire.push(JSON.stringify({ at: Date.now(), direction, message }));
}
};
socket.on("framesent", ({ payload }) => recordFrame("sent", payload.toString()));
socket.on("framereceived", ({ payload }) => recordFrame("received", payload.toString()));
});
return wire;
}
async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, info: TestInfo) {
const repo = await createTempGitRepo("codex-worktree-restore-");
const mcp = new Client({ name: "worktree-codex-restore-playwright", version: "1.0.0" });
const wire = recordTimelineWire(page);
let workspace: z.infer<typeof workspaceSchema> | undefined;
let agentId: string | undefined;
function created() {
if (!workspace || !agentId) throw new Error("Create the Codex worktree before using it");
return { ...workspace, agentId };
}
async function expectSelected() {
await expect(page.getByTestId(`workspace-tab-agent_${created().agentId}`)).toHaveAttribute(
"aria-selected",
"true",
);
}
return {
async createWorktreeWithCodex() {
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents`),
),
);
workspace = workspaceSchema.parse(
await callPaseoTool(mcp, "create_workspace", {
isolation: "worktree",
path: repo.path,
baseBranch: "main",
title: "Codex worktree restore reproduction",
}),
);
const agent = z.object({ agentId: z.string() }).parse(
await callPaseoTool(mcp, "create_agent", {
workspaceId: workspace.workspaceId,
provider: `codex/${await defaultCodexModel(mcp)}`,
settings: { modeId: "full-access", thinkingOptionId: "low" },
title: "Codex worktree restore reproduction",
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
background: true,
}),
);
agentId = agent.agentId;
},
async waitForCompletedReply() {
const target = created();
await page.goto(buildHostAgentDetailRoute(getServerId(), target.agentId, target.workspaceId));
await waitForSidebarHydration(page);
await expect(page.getByTestId("assistant-message")).toContainText(REPLY, {
timeout: 240_000,
});
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { status: "idle", archivedAt: null },
});
await assertComposerIdle({ page });
},
async archiveBeforeBrowserConnects() {
const target = created();
await client.waitForFinish(target.agentId, 240_000);
expect((await client.archiveWorkspace(target.workspaceId)).error).toBeNull();
await expect.poll(() => existsSync(target.cwd)).toBe(false);
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { archivedAt: expect.any(String) },
});
},
async connectFreshBrowser() {
await page.goto("/");
},
async archiveWorkspaceFromSidebar() {
await archiveWorkspaceFromSidebar(page, created().workspaceId);
},
async expectWorkspaceAndAgentArchived() {
const target = created();
await expectWorkspaceAbsentFromSidebar(page, target.workspaceId);
await expect
.poll(() => client.fetchAgent({ agentId: target.agentId }))
.toMatchObject({
agent: { archivedAt: expect.any(String) },
});
await expect.poll(() => existsSync(target.cwd), { timeout: 30_000 }).toBe(false);
await expect
.poll(() => client.fetchWorkspaces())
.not.toMatchObject({
entries: expect.arrayContaining([expect.objectContaining({ id: target.workspaceId })]),
});
},
async openArchivedWorkspaceFromHistory() {
await openSessions(page);
const row = page.getByTestId(`agent-row-${getServerId()}-${created().agentId}`);
await expect(row).toContainText("Archived");
await row.click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
},
async restoreWorkspace() {
await page.getByRole("button", { name: "Restore", exact: true }).click();
await expect(page.getByText("Workspace archived", { exact: true })).toHaveCount(0, {
timeout: 60_000,
});
},
async expectWorktreeRestoredWithArchivedAgentSelected() {
await expect.poll(() => existsSync(created().cwd), { timeout: 30_000 }).toBe(true);
await expectSelected();
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible();
await info.attach("after-workspace-restore", {
body: JSON.stringify(await client.fetchAgent({ agentId: created().agentId }), null, 2),
contentType: "application/json",
});
},
async expectArchivedAgentSelectedWithHistory() {
await expectSelected();
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toContainText(REPLY);
},
async reloadWithoutAgentCache() {
const url = page.url();
const cdp = await page.context().newCDPSession(page);
// Unmount first so an open connection cannot repopulate the cache while it is cleared.
await page.goto("about:blank");
await cdp.send("Storage.clearDataForOrigin", {
origin: new URL(url).origin,
storageTypes: "indexeddb,cache_storage",
});
await cdp.send("Network.clearBrowserCache");
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await page.goto(url);
await cdp.detach();
},
async waitForWorkspaceHydration() {
await waitForSidebarHydration(page);
},
async unarchiveAgent() {
await page.getByRole("button", { name: "Unarchive", exact: true }).click({ timeout: 30_000 });
await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, {
timeout: 60_000,
});
},
async expectIdleAgentWithVisibleComposer(statusTimeout?: number) {
await expect
.poll(() => client.fetchAgent({ agentId: created().agentId }), { timeout: statusTimeout })
.toMatchObject({ agent: { status: "idle", archivedAt: null } });
await expectSelected();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await assertComposerIdle({ page });
await info.attach("restored-agent-screen", {
body: await page.screenshot(),
contentType: "image/png",
});
await info.attach("restored-agent-accessibility", {
body: await page.locator("body").ariaSnapshot(),
contentType: "text/plain",
});
},
async dispose() {
const testFailed = info.status !== info.expectedStatus;
const errors: unknown[] = [];
try {
await info.attach("final-screen", {
body: await page.screenshot(),
contentType: "image/png",
});
await info.attach("timeline-wire", { body: wire.join("\n"), contentType: "text/plain" });
await info.attach("final-agent", {
body: JSON.stringify(agentId ? await client.fetchAgent({ agentId }) : null, null, 2),
contentType: "application/json",
});
await info.attach("daemon-log", {
body: await readFile(path.join(process.env.E2E_PASEO_HOME!, "daemon.log")),
contentType: "text/plain",
});
} catch (error) {
errors.push(error);
}
const cleanup: Array<() => Promise<unknown>> = [() => mcp.close()];
if (!testFailed || process.env.E2E_KEEP_PASEO_HOME !== "1") {
const projectId = workspace?.projectId;
if (projectId) cleanup.push(() => client.removeProject(projectId));
cleanup.push(() => repo.cleanup());
}
for (const release of cleanup) {
try {
await release();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
const error = new AggregateError(errors, "Codex restore fixture teardown failed");
if (!testFailed) throw error;
// Keep the journey failure primary while reporting teardown failures in the test log.
console.error(error);
}
},
};
}
export const test = base.extend<{
codexRestore: Awaited<ReturnType<typeof createCodexRestoreJourney>>;
}>({
// This fixture owns cleanup so a failed reproduction can retain its daemon state.
projectOwnership: async ({ e2eWorkerClient }, provide) => {
void e2eWorkerClient;
await provide();
},
codexRestore: async ({ page, e2eWorkerClient }, provide, info) => {
const journey = await createCodexRestoreJourney(page, e2eWorkerClient, info);
try {
await provide(journey);
} finally {
await journey.dispose();
}
},
});
@@ -115,8 +115,7 @@ function HostWorkspaceRouteContent() {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
const isAgentOpenIntent = recoveryAgentId !== null;
const isAgentOpenIntent = openIntent?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
@@ -191,16 +190,10 @@ function HostWorkspaceRouteContent() {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
}
function WorkspaceDeck({
recoveryRequested,
recoveryAgentId,
}: {
recoveryRequested: boolean;
recoveryAgentId: string | null;
}) {
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
const activeSelection = useActiveWorkspaceSelection();
const [retainedSelections, setRetainedSelections] = useState<RetainedWorkspaceSelection[]>(() =>
activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [],
@@ -272,7 +265,6 @@ function WorkspaceDeck({
selection={selection}
active={active}
recoveryRequested={recoveryRequested}
recoveryAgentId={recoveryAgentId}
onUnmountInactive={unmountWorkspaceSelection}
/>
);
@@ -286,13 +278,11 @@ function WorkspaceDeckEntry({
selection,
active,
recoveryRequested,
recoveryAgentId,
onUnmountInactive,
}: {
selection: ActiveWorkspaceSelection;
active: boolean;
recoveryRequested: boolean;
recoveryAgentId: string | null;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
@@ -323,7 +313,6 @@ function WorkspaceDeckEntry({
workspaceId={selection.workspaceId}
isRouteFocused={active}
recoveryRequested={active && recoveryRequested}
recoveryAgentId={active ? recoveryAgentId : null}
/>
</RetainedPanel>
);
+1 -25
View File
@@ -626,8 +626,6 @@ function AgentPanelBody({
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
const lookupAttemptTokenRef = useRef(0);
const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []);
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent);
useEffect(() => {
lookupAttemptTokenRef.current += 1;
@@ -639,9 +637,6 @@ function AgentPanelBody({
return;
}
if (agentState.id) {
if (workspaceKey) {
resolvePendingAgent(workspaceKey, agentId);
}
if (lookupState.tag !== "idle") {
setLookupState({ tag: "idle" });
}
@@ -664,9 +659,6 @@ function AgentPanelBody({
return;
}
if (!result) {
if (workspaceKey) {
resolvePendingAgent(workspaceKey, agentId);
}
setLookupState({
tag: "not_found",
message: `Agent not found: ${agentId}`,
@@ -675,9 +667,6 @@ function AgentPanelBody({
}
storeFetchedAgentDetail({ serverId, result });
if (workspaceKey) {
resolvePendingAgent(workspaceKey, agentId);
}
setLookupState({ tag: "idle" });
return;
})
@@ -687,25 +676,12 @@ 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,
resolvePendingAgent,
serverId,
workspaceKey,
]);
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
if (lookupState.tag === "not_found") {
return (
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import {
selectAgentTimelineState,
selectAgentTurnPresentation,
useSessionStore,
} from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
import { AgentDirectoryReplica } from "./agent-replica";
@@ -53,6 +57,50 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry {
}
describe("AgentDirectoryReplica", () => {
it.each(["delta", "snapshot"] as const)(
"does not erase freshly fetched history when the active directory removes it by %s",
(removal) => {
const serverId = `agent-replica-history-${removal}`;
const store = useSessionStore.getState();
store.initializeSession(serverId, null);
const replica = new AgentDirectoryReplica(
serverId,
() => undefined,
() => undefined,
);
const archived = { ...payload("archived"), archivedAt: "2026-09-11T00:00:00.000Z" };
replica.submitTimelineAgent(replica.captureTimeline("agent"), archived);
store.applyAgentTimelineResponseState(serverId, "agent", {
items: [
{
kind: "assistant_message",
id: "reply",
text: "server history",
timestamp: new Date(),
timelineCursor: { epoch: "e", seq: 1 },
},
],
head: [],
range: { epoch: "e", startSeq: 1, endSeq: 1 },
older: "none",
newer: false,
synchronized: true,
acknowledgedClientMessageIds: [],
});
if (removal === "delta") replica.applyDelta({ kind: "remove", agentId: "agent" });
else replica.commitSnapshot([], []);
expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({
status: "synced",
items: [{ text: "server history" }],
});
replica.remove("agent");
expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({
status: "cold",
});
store.clearSession(serverId);
},
);
it("does not let a late cache read replace newer live turn state", () => {
const serverId = "agent-replica-late-cache";
const store = useSessionStore.getState();
@@ -134,7 +134,7 @@ export class AgentDirectoryReplica {
if (!this.members.has(agentId)) this.advance(agentId);
}
for (const agentId of previous.keys()) {
if (!nextIds.has(agentId)) this.storeProjection.remove(agentId);
if (!nextIds.has(agentId)) this.storeProjection.removeFromDirectory(agentId);
}
this.members.clear();
this.pendingCacheReads.clear();
@@ -12,6 +12,15 @@ import { useDraftStore } from "@/stores/draft-store";
import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization";
import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness";
function withoutAgent(agentId: string) {
return <T>(current: Map<string, T>): Map<string, T> => {
if (!current.has(agentId)) return current;
const next = new Map(current);
next.delete(agentId);
return next;
};
}
function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent {
if (!previous) return incoming;
const activeTurn =
@@ -65,7 +74,7 @@ export class AgentStoreProjection {
agent?: Agent;
} {
if (delta.kind === "remove") {
this.remove(delta.agentId);
this.removeFromDirectory(delta.agentId);
return { agentId: delta.agentId, stoppedRunning: false };
}
const normalized = normalizeAgentSnapshot(delta.agent, this.serverId);
@@ -141,28 +150,19 @@ export class AgentStoreProjection {
useSessionStore.getState().setPendingPermissions(this.serverId, pending);
}
remove(agentId: string): void {
removeFromDirectory(agentId: string): void {
const store = useSessionStore.getState();
const removeKey = <T>(current: Map<string, T>): Map<string, T> => {
if (!current.has(agentId)) return current;
const next = new Map(current);
next.delete(agentId);
return next;
};
const removeKey = withoutAgent(agentId);
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
store.setAgents(this.serverId, removeKey);
store.setAgentDetails(this.serverId, removeKey);
store.setQueuedMessages(this.serverId, removeKey);
store.setAgentTimelineCursor(this.serverId, removeKey);
store.setInitializingAgents(this.serverId, removeKey);
store.setPendingPermissions(this.serverId, (current) => {
const next = new Map(current);
for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key);
return next.size === current.size ? current : next;
});
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
store.setAgentStreamTail(this.serverId, removeKey);
store.clearAgentStreamHead(this.serverId, agentId);
useSessionStore.setState((state) => {
if (!state.agentLastActivity.has(agentId)) return state;
const agentLastActivity = new Map(state.agentLastActivity);
@@ -178,6 +178,18 @@ export class AgentStoreProjection {
}
}
remove(agentId: string): void {
this.removeFromDirectory(agentId);
// Only entity deletion destroys the transcript. A filtered active-list
// response can arrive after a fresh history response for an archived agent.
const store = useSessionStore.getState();
const removeKey = withoutAgent(agentId);
store.setAgentTimelineCursor(this.serverId, removeKey);
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
store.setAgentStreamTail(this.serverId, removeKey);
store.clearAgentStreamHead(this.serverId, agentId);
}
archive(agentId: string, archivedAt: string): Agent | null {
let archived: Agent | null = null;
useSessionStore.getState().setAgents(this.serverId, (current) => {
@@ -257,13 +257,11 @@ 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 {
@@ -859,7 +857,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
workspaceId,
isRouteFocused,
recoveryRequested,
recoveryAgentId,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
useEffect(() => {
@@ -874,7 +871,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
workspaceId={workspaceId}
isRouteFocused={isRouteFocused ?? navigationFocused}
recoveryRequested={recoveryRequested ?? false}
recoveryAgentId={recoveryAgentId ?? null}
/>
);
});
@@ -1539,7 +1535,6 @@ function WorkspaceScreenContent({
workspaceId,
isRouteFocused,
recoveryRequested,
recoveryAgentId,
}: WorkspaceScreenContentProps) {
const { t } = useTranslation();
const _insets = useSafeAreaInsets();
@@ -1665,7 +1660,6 @@ function WorkspaceScreenContent({
const workspaceRecovery = useWorkspaceRecovery({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
agentId: recoveryAgentId,
enabled: shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces,
workspaceDescriptor,
@@ -252,7 +252,6 @@ interface ReorderPaneTabsInLayoutInput {
export interface WorkspaceTabReconcileState {
layout: WorkspaceLayout;
pinnedAgentIds?: ReadonlySet<string> | null;
pendingAgentIds?: ReadonlySet<string> | null;
hiddenAgentIds?: ReadonlySet<string> | null;
explorerSidebarPaneId: string | null;
}
@@ -262,7 +261,6 @@ export interface WorkspaceTabSnapshot {
terminalsHydrated: boolean;
activeAgentIds: Iterable<string>;
autoOpenAgentIds: Iterable<string>;
knownAgentIds: Iterable<string>;
knownTerminalIds?: Iterable<string>;
standaloneTerminalIds: Iterable<string>;
hasActivePendingTerminalCreate?: boolean;
@@ -2238,17 +2236,10 @@ interface EntityTabGroup {
function applyPinnedAndHidden(input: {
baseAgentIds: Set<string>;
pinnedAgentIds: Set<string>;
pendingAgentIds: Set<string>;
hiddenAgentIds: Set<string>;
knownAgentIds: Set<string>;
}): Set<string> {
const { baseAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds } = input;
const result = new Set(baseAgentIds);
for (const agentId of pinnedAgentIds) {
if (knownAgentIds.has(agentId) || pendingAgentIds.has(agentId)) {
result.add(agentId);
}
}
const { baseAgentIds, pinnedAgentIds, hiddenAgentIds } = input;
const result = new Set([...baseAgentIds, ...pinnedAgentIds]);
for (const agentId of hiddenAgentIds) {
result.delete(agentId);
}
@@ -2414,12 +2405,17 @@ export function reconcileWorkspaceTabs(
const originalFocusedTabId =
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);
const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds);
// An explicit open owns the tab until the agent joins the active directory.
// From then on it follows the normal server archive lifecycle. Detail/cache
// hydration never decides whether the user's target is allowed to stay open.
const pinnedAgentIds = new Set(
[...(state.pinnedAgentIds ?? [])].filter(
(agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId),
),
);
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
const knownTerminalIds = snapshot.knownTerminalIds
? normalizeStringSet(snapshot.knownTerminalIds)
@@ -2427,16 +2423,12 @@ export function reconcileWorkspaceTabs(
const visibleAgentIds = applyPinnedAndHidden({
baseAgentIds: activeAgentIds,
pinnedAgentIds,
pendingAgentIds,
hiddenAgentIds,
knownAgentIds,
});
const autoOpenSet = applyPinnedAndHidden({
baseAgentIds: autoOpenAgentIds,
pinnedAgentIds,
pendingAgentIds,
hiddenAgentIds,
knownAgentIds,
});
const initialTabs = collectAllTabs(nextLayout.root);
@@ -2513,5 +2505,6 @@ export function reconcileWorkspaceTabs(
return {
...state,
layout: nextLayout,
pinnedAgentIds,
};
}
@@ -450,7 +450,7 @@ describe("workspace-layout-store version 2 migration", () => {
});
});
it("writes version 2 with the persisted Explorer key accepted by released v0.6", async () => {
it("writes version 2 preserving the existing Explorer key", async () => {
await persistVersionOneLayout("v0.6");
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
@@ -463,6 +463,7 @@ describe("workspace-layout-store version 2 migration", () => {
"explorerPaneIdByWorkspace",
"explorerSidebarWidthByWorkspace",
"layoutByWorkspace",
"pinnedAgentIdsByWorkspace",
"sidePaneIdByWorkspace",
"splitSizesByWorkspace",
]);
@@ -1483,7 +1484,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: ["terminal-1"],
standaloneTerminalIds: ["terminal-1"],
hasActivePendingTerminalCreate: true,
@@ -1497,7 +1497,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: ["terminal-1"],
standaloneTerminalIds: ["terminal-1"],
});
@@ -1580,7 +1579,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: ["agent-1", "agent-2"],
autoOpenAgentIds: ["agent-1", "agent-2"],
knownAgentIds: ["agent-1", "agent-2"],
standaloneTerminalIds: [],
});
@@ -2943,6 +2941,7 @@ describe("workspace-layout-store actions", () => {
expect(persisted).toEqual({
layoutByWorkspace: { [workspaceKey]: layout },
pinnedAgentIdsByWorkspace: {},
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace,
explorerPaneIdByWorkspace: {},
@@ -3092,7 +3091,7 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true);
});
it("keeps explicitly opened pinned agents in memory per workspace without persisting them", () => {
it("persists explicit agent opens per workspace", () => {
const workspaceKey = createWorkspaceKey();
const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
@@ -3131,7 +3130,9 @@ describe("workspace-layout-store actions", () => {
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).not.toHaveProperty("pinnedAgentIdsByWorkspace");
expect(partialize?.(state)).toHaveProperty("pinnedAgentIdsByWorkspace", {
[otherWorkspaceKey as string]: ["agent-2"],
});
});
it("keeps hidden agent intents in memory per workspace without persisting them", () => {
@@ -3166,6 +3167,7 @@ describe("workspace-layout-store actions", () => {
expect(partialize).toBeTypeOf("function");
expect(partialize?.(state)).toEqual({
layoutByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
splitSizesByWorkspace: {},
explorerSidebarWidthByWorkspace: {},
explorerPaneIdByWorkspace: {},
@@ -3258,7 +3260,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
autoOpenAgentIds: ["agent-1"],
knownAgentIds: ["agent-1", "agent-2"],
standaloneTerminalIds: ["term-1"],
hasActivePendingDraftCreate: false,
});
@@ -3314,7 +3315,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
autoOpenAgentIds: ["agent-1"],
knownAgentIds: ["agent-1"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
@@ -3346,7 +3346,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
autoOpenAgentIds: ["agent-1"],
knownAgentIds: ["agent-1"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
@@ -3362,7 +3361,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
autoOpenAgentIds: ["agent-1"],
knownAgentIds: ["agent-1"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
@@ -3380,7 +3378,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
@@ -3444,7 +3441,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: false,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
});
@@ -3467,7 +3463,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
@@ -3495,7 +3490,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: ["parent-agent", "child-agent"],
autoOpenAgentIds: ["parent-agent"],
knownAgentIds: ["parent-agent", "child-agent"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
@@ -3522,7 +3516,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: ["parent-agent", "child-agent"],
autoOpenAgentIds: ["parent-agent"],
knownAgentIds: ["parent-agent", "child-agent"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
@@ -3549,7 +3542,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: ["parent-agent"],
autoOpenAgentIds: ["parent-agent"],
knownAgentIds: ["parent-agent", "child-agent"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
@@ -3571,7 +3563,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: ["child-agent"],
autoOpenAgentIds: [],
knownAgentIds: ["child-agent"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
});
@@ -3609,7 +3600,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: ["term-script", "term-manual"],
standaloneTerminalIds: ["term-manual"],
hasActivePendingDraftCreate: false,
@@ -3629,7 +3619,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
knownTerminalIds: ["term-script"],
standaloneTerminalIds: [],
hasActivePendingDraftCreate: false,
@@ -3675,7 +3664,7 @@ 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", () => {
it("keeps an explicitly opened agent when its detail leaves client memory", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
@@ -3690,7 +3679,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
standaloneTerminalIds: [],
});
@@ -3706,7 +3694,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
standaloneTerminalIds: [],
});
@@ -3717,13 +3704,11 @@ describe("workspace-layout-store actions", () => {
.map((tab) => tab.tabId),
).toEqual(["agent_archived-agent"]);
store.resolvePendingAgent(workspaceKey, "archived-agent");
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
standaloneTerminalIds: [],
});
@@ -3732,7 +3717,7 @@ describe("workspace-layout-store actions", () => {
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.tabId),
).toEqual([]);
).toEqual(["agent_archived-agent"]);
store.openTab({
workspaceKey: workspaceKey,
@@ -3745,7 +3730,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
knownAgentIds: [],
standaloneTerminalIds: [],
});
@@ -3757,6 +3741,57 @@ describe("workspace-layout-store actions", () => {
).toEqual(["agent_archived-agent"]);
});
it("restores the selected historical agent before any server data or cache is available", async () => {
const workspaceKey = createWorkspaceKey();
workspaceLayoutStore.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId: "archived-agent" },
intent: "reveal",
pin: true,
});
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
restored.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
standaloneTerminalIds: [],
});
const layout = restored.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(
"agent_archived-agent",
);
});
it("follows server archive after an explicitly opened agent becomes active", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTab({
workspaceKey,
target: { kind: "agent", agentId: "agent-1" },
intent: "reveal",
pin: true,
});
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: ["agent-1"],
autoOpenAgentIds: ["agent-1"],
standaloneTerminalIds: [],
});
store.reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: true,
activeAgentIds: [],
autoOpenAgentIds: [],
standaloneTerminalIds: [],
});
expect(store.getWorkspaceTabs(workspaceKey).some((tab) => tab.target.kind === "agent")).toBe(
false,
);
});
it("atomically reveals, focuses, and pins an archived agent against reconciliation", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
@@ -3783,7 +3818,6 @@ describe("workspace-layout-store actions", () => {
terminalsHydrated: true,
activeAgentIds: ["first-agent", "second-agent"],
autoOpenAgentIds: ["first-agent", "second-agent"],
knownAgentIds: ["first-agent", "second-agent"],
standaloneTerminalIds: [],
});
@@ -107,7 +107,6 @@ interface WorkspaceLayoutStore {
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
explorerSidebarWidthByWorkspace: Record<string, number>;
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
pendingAgentIdsByWorkspace: Record<string, Set<string>>;
hiddenAgentIdsByWorkspace: Record<string, Set<string>>;
focusRestorationByWorkspace: Record<string, WorkspaceFocusRestorationState>;
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
@@ -130,7 +129,6 @@ interface WorkspaceLayoutStore {
setTabState: (workspaceKey: string, tabId: string, state: JsonValue | undefined) => void;
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: (
@@ -270,6 +268,7 @@ const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject(
parentTabIdByTabId: z.record(z.string(), z.string()).optional(),
});
const WorkspaceLayoutPersistedStateSchema = z.strictObject({
pinnedAgentIdsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
explorerSidebarWidthByWorkspace: z.record(z.string(), z.number()).optional(),
@@ -764,7 +763,6 @@ export function createWorkspaceLayoutStore(
splitSizesByWorkspace: {},
explorerSidebarWidthByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
pendingAgentIdsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
focusRestorationByWorkspace: {},
explorerSidebarPaneIdByWorkspace: {},
@@ -830,13 +828,6 @@ export function createWorkspaceLayoutStore(
normalizedTarget.agentId,
)
: state.pinnedAgentIdsByWorkspace,
pendingAgentIdsByWorkspace: shouldPinAgent
? addAgentIdToWorkspaceSet(
state.pendingAgentIdsByWorkspace,
normalizedWorkspaceKey,
normalizedTarget.agentId,
)
: state.pendingAgentIdsByWorkspace,
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: input.parentTabId
@@ -1212,7 +1203,6 @@ export function createWorkspaceLayoutStore(
{
layout: currentLayout,
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
explorerSidebarPaneId,
},
@@ -1223,11 +1213,25 @@ export function createWorkspaceLayoutStore(
explorerSidebarPaneId,
currentLayout.focusedPaneId,
);
if (nextLayout === rawLayout) {
let pinnedAgentIdsByWorkspace = state.pinnedAgentIdsByWorkspace;
for (const agentId of state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? []) {
if (!nextState.pinnedAgentIds?.has(agentId)) {
pinnedAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet(
pinnedAgentIdsByWorkspace,
normalizedWorkspaceKey,
agentId,
);
}
}
if (
nextLayout === rawLayout &&
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace
) {
return state;
}
return {
pinnedAgentIdsByWorkspace,
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -1235,25 +1239,6 @@ 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) {
@@ -1676,11 +1661,6 @@ export function createWorkspaceLayoutStore(
delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey];
return {
pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace,
pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
state.pendingAgentIdsByWorkspace,
normalizedWorkspaceKey,
normalizedAgentId,
),
};
}
@@ -1692,11 +1672,6 @@ export function createWorkspaceLayoutStore(
...state.pinnedAgentIdsByWorkspace,
[normalizedWorkspaceKey]: nextPinnedAgentIds,
},
pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet(
state.pendingAgentIdsByWorkspace,
normalizedWorkspaceKey,
normalizedAgentId,
),
};
});
},
@@ -1756,7 +1731,6 @@ export function createWorkspaceLayoutStore(
normalizedWorkspaceKey in state.splitSizesByWorkspace ||
normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace ||
normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace ||
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
@@ -1774,8 +1748,6 @@ export function createWorkspaceLayoutStore(
} = state.explorerSidebarWidthByWorkspace;
const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } =
state.pinnedAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _pending, ...pendingAgentIdsByWorkspace } =
state.pendingAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } =
state.hiddenAgentIdsByWorkspace;
const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } =
@@ -1791,7 +1763,6 @@ export function createWorkspaceLayoutStore(
splitSizesByWorkspace,
explorerSidebarWidthByWorkspace,
pinnedAgentIdsByWorkspace,
pendingAgentIdsByWorkspace,
hiddenAgentIdsByWorkspace,
focusRestorationByWorkspace,
explorerSidebarPaneIdByWorkspace,
@@ -1817,6 +1788,12 @@ export function createWorkspaceLayoutStore(
}
return {
layoutByWorkspace,
pinnedAgentIdsByWorkspace: Object.fromEntries(
Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
key,
Array.from(agentIds),
]),
),
splitSizesByWorkspace: state.splitSizesByWorkspace,
explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace,
explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace,
@@ -1855,6 +1832,12 @@ export function createWorkspaceLayoutStore(
return {
...currentState,
layoutByWorkspace,
pinnedAgentIdsByWorkspace: Object.fromEntries(
Object.entries(result.data.pinnedAgentIdsByWorkspace ?? {}).map(([key, agentIds]) => [
key,
new Set(agentIds),
]),
),
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
explorerSidebarWidthByWorkspace:
result.data.explorerSidebarWidthByWorkspace ??
@@ -187,55 +187,60 @@ describe("replaceFetchedAgentDirectory", () => {
store.clearSession(serverId);
});
it("removes every replica-owned artifact for a removed agent", () => {
const serverId = "server-removal";
const agentId = "removed-agent";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const agent = {
...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId),
projectPlacement: null,
};
store.setAgents(serverId, new Map([[agentId, agent]]));
store.setAgentDetails(serverId, new Map([[agentId, agent]]));
store.setQueuedMessages(
serverId,
new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]),
);
store.setAgentTimelineCursor(
serverId,
new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]),
);
store.setPendingPermissions(
serverId,
new Map([["permission", { key: "permission", agentId, request: null as never }]]),
);
store.setInitializingAgents(serverId, new Map([[agentId, true]]));
setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true });
it.each(["directory", "entity"] as const)(
"cleans removed %s state without invalidating an independent transcript",
(kind) => {
const serverId = "server-removal";
const agentId = "removed-agent";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const agent = {
...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId),
projectPlacement: null,
};
store.setAgents(serverId, new Map([[agentId, agent]]));
store.setAgentDetails(serverId, new Map([[agentId, agent]]));
store.setQueuedMessages(
serverId,
new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]),
);
store.setAgentTimelineCursor(
serverId,
new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]),
);
store.setPendingPermissions(
serverId,
new Map([["permission", { key: "permission", agentId, request: null as never }]]),
);
store.setInitializingAgents(serverId, new Map([[agentId, true]]));
setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true });
new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId });
const projection = new AgentStoreProjection(serverId);
if (kind === "directory") projection.applyDelta({ kind: "remove", agentId });
else projection.remove(agentId);
const session = useSessionStore.getState().sessions[serverId];
expect({
agents: session?.agents.has(agentId),
details: session?.agentDetails.has(agentId),
queued: session?.queuedMessages.has(agentId),
cursor: session?.agentTimelineCursor.has(agentId),
permissions: session?.pendingPermissions.size,
initializing: session?.initializingAgents.has(agentId),
archivePending: isAgentArchiving({ queryClient, serverId, agentId }),
}).toEqual({
agents: false,
details: false,
queued: false,
cursor: false,
permissions: 0,
initializing: false,
archivePending: false,
});
const session = useSessionStore.getState().sessions[serverId];
expect({
agents: session?.agents.has(agentId),
details: session?.agentDetails.has(agentId),
queued: session?.queuedMessages.has(agentId),
cursor: session?.agentTimelineCursor.has(agentId),
permissions: session?.pendingPermissions.size,
initializing: session?.initializingAgents.has(agentId),
archivePending: isAgentArchiving({ queryClient, serverId, agentId }),
}).toEqual({
agents: false,
details: false,
queued: false,
cursor: kind === "directory",
permissions: 0,
initializing: false,
archivePending: false,
});
store.clearSession(serverId);
});
store.clearSession(serverId);
},
);
it("keeps newer metadata while accepting usage-only updates and legacy workspace ownership", () => {
const serverId = "server-usage";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { recoverWorkspaceSelection, resolveWorkspaceRecoveryModel } from "./model";
import { resolveWorkspaceRecoveryModel } from "./model";
describe("resolveWorkspaceRecoveryModel", () => {
it("keeps newer recovery actions visible but non-actionable", () => {
@@ -29,24 +29,3 @@ 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"]);
});
});
@@ -34,22 +34,6 @@ export interface WorkspaceRecoveryController {
retryInspection: () => void;
}
export interface WorkspaceSelectionRecoveryClient {
restoreWorkspace: (workspaceId: string) => Promise<unknown>;
refreshAgent: (agentId: string) => Promise<unknown>;
}
export async function recoverWorkspaceSelection(input: {
client: WorkspaceSelectionRecoveryClient;
workspaceId: string;
agentId?: string | null;
}): Promise<void> {
await input.client.restoreWorkspace(input.workspaceId);
if (input.agentId) {
await input.client.refreshAgent(input.agentId);
}
}
function resolveRecoveryPhase(input: {
pending: boolean;
error: string | null;
@@ -4,11 +4,7 @@ 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 {
recoverWorkspaceSelection,
resolveWorkspaceRecoveryModel,
type WorkspaceRecoveryController,
} from "./model";
import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model";
export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model";
@@ -32,7 +28,6 @@ function waitForMinimumRecoveryLoadingTime(): Promise<void> {
export function useWorkspaceRecovery(input: {
serverId: string;
workspaceId: string;
agentId?: string | null;
enabled: boolean;
}): WorkspaceRecoveryController {
const client = useHostRuntimeClient(input.serverId);
@@ -61,11 +56,7 @@ export function useWorkspaceRecovery(input: {
}
await waitForRecoveryLoadingPresentation();
await waitForMinimumRecoveryLoadingTime();
await recoverWorkspaceSelection({
client,
workspaceId: input.workspaceId,
agentId: input.agentId,
});
await client.restoreWorkspace(input.workspaceId);
},
});
@@ -61,7 +61,7 @@ function makeAgent(input: {
const WORKSPACE_ID = "ws-1";
describe("workspace agent visibility", () => {
it("keeps subagents active and known while excluding them from auto-open", () => {
it("keeps subagents active while excluding them from auto-open", () => {
const parent = makeAgent({
id: "parent-agent",
cwd: "/repo/worktree",
@@ -84,10 +84,9 @@ describe("workspace agent visibility", () => {
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
});
it("keeps archived subagents known but excludes them from active and auto-open", () => {
it("excludes archived subagents from active and auto-open", () => {
const archivedChild = makeAgent({
id: "archived-child",
cwd: "/repo/worktree",
@@ -103,7 +102,6 @@ describe("workspace agent visibility", () => {
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.autoOpenAgentIds).toEqual(new Set<string>());
expect(result.knownAgentIds).toEqual(new Set(["archived-child"]));
});
it("excludes a child from auto-open even when its snapshot arrives before the parent", () => {
@@ -129,7 +127,6 @@ describe("workspace agent visibility", () => {
expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["child-agent", "parent-agent"]));
});
it("auto-opens a subagent whose parent belongs to another workspace", () => {
@@ -155,10 +152,9 @@ describe("workspace agent visibility", () => {
expect(result.activeAgentIds).toEqual(new Set(["child-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["child-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["child-agent"]));
});
it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => {
it("excludes archived agents from the active directory", () => {
const visible = makeAgent({
id: "visible-agent",
cwd: "/repo/worktree",
@@ -191,12 +187,9 @@ describe("workspace agent visibility", () => {
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["visible-agent"]));
expect(result.knownAgentIds.has("visible-agent")).toBe(true);
expect(result.knownAgentIds.has("archived-agent")).toBe(true);
expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false);
});
it("treats lazy historical details as known without making them active", () => {
it("does not make historical details active", () => {
const active = makeAgent({
id: "active-agent",
cwd: "/repo/worktree",
@@ -216,7 +209,6 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set(["active-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["active-agent", "historical-agent"]));
});
it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => {
@@ -281,7 +273,6 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"]));
expect(result.knownAgentIds).toEqual(new Set(["stamped-agent"]));
});
it("excludes a stamped agent whose workspaceId belongs to another workspace sharing the cwd", () => {
@@ -302,7 +293,6 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.knownAgentIds).toEqual(new Set<string>());
});
it("excludes agents without a workspaceId", () => {
@@ -316,14 +306,12 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.knownAgentIds).toEqual(new Set<string>());
});
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
const agentVisibility = {
activeAgentIds: new Set(["active-agent"]),
autoOpenAgentIds: new Set(["root-agent"]),
knownAgentIds: new Set(["active-agent", "archived-agent"]),
};
expect(
@@ -341,7 +329,6 @@ describe("workspace agent visibility", () => {
terminalsHydrated: true,
activeAgentIds: agentVisibility.activeAgentIds,
autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
knownAgentIds: agentVisibility.knownAgentIds,
knownTerminalIds: ["terminal-1", "script-terminal"],
standaloneTerminalIds: ["terminal-1"],
hasActivePendingTerminalCreate: false,
@@ -354,12 +341,10 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a", "b", "c"]),
};
const b = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a", "b", "c"]),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
});
@@ -368,12 +353,10 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a"]),
autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a"]),
};
const b = {
activeAgentIds: new Set(["b"]),
autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a"]),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
});
@@ -382,26 +365,10 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a", "b"]),
};
const b = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["b"]),
knownAgentIds: new Set(["a", "b"]),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
});
it("returns false when knownAgentIds differ", () => {
const a = {
activeAgentIds: new Set(["a"]),
autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a"]),
};
const b = {
activeAgentIds: new Set(["a"]),
autoOpenAgentIds: new Set(["a"]),
knownAgentIds: new Set(["a", "b"]),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
});
@@ -410,12 +377,10 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
knownAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
knownAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
});
@@ -6,7 +6,6 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export interface WorkspaceAgentVisibility {
activeAgentIds: Set<string>;
autoOpenAgentIds: Set<string>;
knownAgentIds: Set<string>;
}
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
@@ -24,13 +23,11 @@ export function deriveWorkspaceAgentVisibility(input: {
return {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
knownAgentIds: new Set<string>(),
};
}
const activeAgentIds = new Set<string>();
const autoOpenAgentIds = new Set<string>();
const knownAgentIds = new Set<string>();
const agentsById = new Map<string, Agent>([
...(agentDetails?.entries() ?? []),
...(sessionAgents?.entries() ?? []),
@@ -39,7 +36,6 @@ export function deriveWorkspaceAgentVisibility(input: {
if (!agentBelongsToWorkspace(agent, workspaceId)) {
continue;
}
knownAgentIds.add(agent.id);
if (!agent.archivedAt) {
activeAgentIds.add(agent.id);
const parentAgent = agent.parentAgentId ? agentsById.get(agent.parentAgentId) : undefined;
@@ -48,14 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: {
}
}
}
for (const agent of agentDetails?.values() ?? []) {
if (!agentBelongsToWorkspace(agent, workspaceId)) {
continue;
}
knownAgentIds.add(agent.id);
}
return { activeAgentIds, autoOpenAgentIds, knownAgentIds };
return { activeAgentIds, autoOpenAgentIds };
}
export function buildWorkspaceTabSnapshot(input: {
@@ -72,7 +61,6 @@ export function buildWorkspaceTabSnapshot(input: {
terminalsHydrated: input.terminalsHydrated,
activeAgentIds: input.agentVisibility.activeAgentIds,
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
knownAgentIds: input.agentVisibility.knownAgentIds,
knownTerminalIds: input.knownTerminalIds,
standaloneTerminalIds: input.standaloneTerminalIds,
hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate,
@@ -86,8 +74,7 @@ export function workspaceAgentVisibilityEqual(
): boolean {
return (
setsEqual(a.activeAgentIds, b.activeAgentIds) &&
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) &&
setsEqual(a.knownAgentIds, b.knownAgentIds)
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds)
);
}
@@ -69,7 +69,7 @@ test("loads archived records for history and active records with the interactive
await ensureAgentLoaded(archived.id, { agentManager: manager, agentStorage: storage, logger });
await ensureAgentLoaded(active.id, { agentManager: manager, agentStorage: storage, logger });
expect(resumeOptions).toEqual([{ purpose: "history" }, undefined]);
expect(resumeOptions).toEqual([{ purpose: "history" }, { purpose: "interactive" }]);
} finally {
await Promise.all([
manager.closeAgent(archivedId).catch(() => undefined),
@@ -37,6 +37,7 @@ import type {
AgentProvider,
AgentPersistenceHandle,
AgentRunOptions,
AgentResumeSessionOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
@@ -5095,14 +5096,14 @@ test("archiveSnapshot clears persisted attention and normalizes running status",
const archivedRecord = await manager.archiveSnapshot(snapshot.id, archivedAt);
expect(archivedRecord.archivedAt).toBe(archivedAt);
expect(archivedRecord.lastStatus).toBe("idle");
expect(archivedRecord.lastStatus).toBe("closed");
expect(archivedRecord.requiresAttention).toBe(false);
expect(archivedRecord.attentionReason).toBeNull();
expect(archivedRecord.attentionTimestamp).toBeNull();
const persisted = await storage.get(snapshot.id);
expect(persisted?.archivedAt).toBe(archivedAt);
expect(persisted?.lastStatus).toBe("idle");
expect(persisted?.lastStatus).toBe("closed");
expect(persisted?.requiresAttention).toBe(false);
expect(persisted?.attentionReason).toBeNull();
expect(persisted?.attentionTimestamp).toBeNull();
@@ -9616,7 +9617,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () =
}
});
test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", async () => {
test("a stored-only archive waits for an in-flight resume and then closes it", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-resume-race-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const resumeStarted = deferred<void>();
@@ -9640,16 +9641,17 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a
});
await manager.closeAgent(agent.id);
const load = ensureUnarchivedAgentLoaded(agent.id, {
const load = ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
await resumeStarted.promise;
await manager.archiveSnapshot(agent.id, new Date().toISOString());
const archive = manager.archiveSnapshot(agent.id, new Date().toISOString());
resumeAllowed.resolve();
await archive;
await expect(load).rejects.toThrow(`Agent is archived: ${agent.id}`);
await expect(load).resolves.toMatchObject({ id: agent.id });
expect(manager.getAgent(agent.id)).toBeNull();
expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String));
} finally {
@@ -9659,7 +9661,7 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a
}
});
test("ensureUnarchivedAgentLoaded fences an archived agent after joining a shared resume", async () => {
test("a stored-only archive closes a shared resume before a later protected load returns", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-shared-resume-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const resumeStarted = deferred<void>();
@@ -9694,8 +9696,9 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share
agentStorage: storage,
logger,
});
await manager.archiveSnapshot(agent.id, new Date().toISOString());
const archive = manager.archiveSnapshot(agent.id, new Date().toISOString());
resumeAllowed.resolve();
await archive;
await sharedLoad;
await expect(protectedLoad).rejects.toThrow(`Agent is archived: ${agent.id}`);
@@ -9933,7 +9936,7 @@ test("concurrent explicit closes tear down the runtime once", async () => {
}
});
test("provider close failure still persists and emits a resumable closed agent", async () => {
test("provider close failure retains the runtime for cleanup instead of allowing another writer", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new (class extends TestAgentClient {
@@ -9953,12 +9956,10 @@ test("provider close failure still persists and emits a resumable closed agent",
"00000000-0000-4000-8000-000000000217",
{ workspaceId: undefined },
);
const closed = waitForAgentLifecycle(manager, created.id, "closed");
await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed");
await closed;
expect(manager.getAgent(created.id)?.session).toBe(created.session);
const stored = await storage.get(created.id);
expect(stored).toMatchObject({ lastStatus: "closed" });
expect(stored).toMatchObject({ lastStatus: "idle" });
expect(stored?.archivedAt).toBeFalsy();
await expect(
@@ -10973,3 +10974,54 @@ test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls"
expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled();
});
test("concurrent native restores run once before resuming the same agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-ownership-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const restoreStarted = deferred<void>();
const restoreAllowed = deferred<void>();
const operations: string[] = [];
const client = new (class extends TestAgentClient {
async unarchiveNativeSession(): Promise<void> {
operations.push("restore");
restoreStarted.resolve();
await restoreAllowed.promise;
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
operations.push(`resume:${options?.purpose}`);
return super.resumeSession(handle, config);
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let agentId: string | undefined;
try {
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = created.id;
await manager.archiveAgent(agentId);
const first = manager.unarchiveSnapshot(agentId);
await restoreStarted.promise;
const second = manager.unarchiveSnapshot(agentId);
const load = ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
});
restoreAllowed.resolve();
await Promise.all([first, second, load]);
expect(operations).toEqual(["restore", "resume:interactive"]);
expect((await storage.get(agentId))?.archivedAt).toBeNull();
expect(manager.getAgent(agentId)?.persistence?.sessionId).toBe(created.persistence?.sessionId);
} finally {
restoreAllowed.resolve();
if (agentId) await manager.closeAgent(agentId);
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
}
});
@@ -1282,8 +1282,20 @@ export class AgentManager {
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(
agentId ?? this.idFactory(),
"resumeAgentFromPersistence",
);
return this.trackAgentRegistrationOperation(
this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options, resumeOptions),
this.runLifecycleMutation(resolvedAgentId, () =>
this.resumeAgentFromPersistenceInternal(
handle,
overrides,
resolvedAgentId,
options,
resumeOptions,
),
),
);
}
@@ -1317,6 +1329,12 @@ export class AgentManager {
resolvedAgentId,
);
// Decide residency from durable state inside the lifecycle lane. A loader may
// have read the record before a queued archive or restore completed.
const record = this.registry ? await this.registry.get(resolvedAgentId) : null;
const currentResumeOptions = record
? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) }
: resumeOptions;
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
if (!available) {
@@ -1333,7 +1351,7 @@ export class AgentManager {
undefined,
{
reason: "resume",
purpose: resumeOptions?.purpose ?? "interactive",
purpose: currentResumeOptions?.purpose ?? "interactive",
workspaceId: options?.workspaceId ?? null,
},
);
@@ -1342,7 +1360,7 @@ export class AgentManager {
handle,
providerLaunchConfig,
launchContext,
resumeOptions,
currentResumeOptions,
);
await this.requireExternalMcpSupport(session, storedConfig);
return this.registerSession(session, storedConfig, resolvedAgentId, {
@@ -1649,14 +1667,11 @@ export class AgentManager {
"agent.manager.close.start",
);
await this.drainSessionEvents(agentId);
// Retain ownership until shutdown succeeds. A failed close may still own a
// native writer, so publishing a resumable closed snapshot would orphan it.
await agent.session.close();
this.cancelRunningProviderSubagents(agentId);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
let closeError: unknown;
try {
await agent.session.close();
} catch (error) {
closeError = error;
}
let persistError: unknown;
try {
@@ -1674,9 +1689,6 @@ export class AgentManager {
"agent.manager.close.complete",
);
if (closeError !== undefined) {
throw closeError;
}
if (persistError !== undefined) {
throw persistError;
}
@@ -1700,7 +1712,10 @@ export class AgentManager {
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
}
private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> {
private async archiveAgentUnlocked(
agentId: string,
requestedArchivedAt?: string,
): Promise<{ archivedAt: string }> {
const agent = this.requireAgent(agentId);
if (!this.registry) {
throw new Error("Agent storage is not configured");
@@ -1714,7 +1729,7 @@ export class AgentManager {
throw new Error(`Agent ${agentId} not found in storage after snapshot`);
}
const { archivedAt } = await this.markRecordArchived(stored);
const { archivedAt } = await this.markRecordArchived(stored, requestedArchivedAt);
agent.updatedAt = new Date(archivedAt);
await this.closeAgentRuntime(agentId);
await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive");
@@ -1764,14 +1779,16 @@ export class AgentManager {
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id);
} else {
await this.archiveSnapshot(currentChild.id, new Date().toISOString());
await this.archiveSnapshotUnlocked(currentChild.id, new Date().toISOString());
}
});
}
}
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
const archivedAt = new Date().toISOString();
private async markRecordArchived(
record: StoredAgentRecord,
archivedAt = new Date().toISOString(),
): Promise<ArchivedStoredAgentRecord> {
const archivedRecord = await this.persistArchivedRecord(record, {
archivedAt,
updatedAt: archivedAt,
@@ -2109,12 +2126,23 @@ export class AgentManager {
}
async archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord> {
return this.runLifecycleMutation(agentId, () =>
this.archiveSnapshotUnlocked(agentId, archivedAt),
);
}
private async archiveSnapshotUnlocked(
agentId: string,
archivedAt: string,
): Promise<StoredAgentRecord> {
const registry = this.requireRegistry();
const liveAgent = this.getAgent(agentId);
if (liveAgent) {
await this.persistSnapshot(liveAgent, {
internal: liveAgent.internal,
});
// A stored-only archive can have waited behind a persisted resume. Reuse the
// live archive transition so its newly acquired runtime is closed as well.
if (this.agents.has(agentId)) {
await this.archiveAgentUnlocked(agentId, archivedAt);
const archivedRecord = await registry.get(agentId);
if (!archivedRecord) throw new Error(`Agent not found: ${agentId}`);
return archivedRecord;
}
const record = await registry.get(agentId);
@@ -2126,14 +2154,8 @@ export class AgentManager {
await this.syncNativeArchiveState(record.provider, record.persistence, "archive");
if (this.agents.has(agentId)) {
this.notifyAgentState(agentId);
} else {
this.discardRetainedAgentState(agentId);
if (!nextRecord.internal) {
this.dispatchStoredAgentState(nextRecord);
}
}
this.discardRetainedAgentState(agentId);
if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord);
await this.fireAgentArchived(agentId);
await this.cascadeArchiveChildren(agentId);
@@ -2144,6 +2166,15 @@ export class AgentManager {
async unarchiveSnapshot(
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
): Promise<boolean> {
return this.runLifecycleMutation(agentId, () =>
this.unarchiveSnapshotUnlocked(agentId, updates),
);
}
private async unarchiveSnapshotUnlocked(
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
): Promise<boolean> {
const registry = this.requireRegistry();
const record = await registry.get(agentId);
@@ -2151,8 +2182,9 @@ export class AgentManager {
return false;
}
// Archived history may have loaded a runtime that still owns the native writer.
await this.closeAgent(agentId);
// Close and native restore share the lifecycle lane with persisted resume.
// No new history or interactive runtime can acquire the writer between them.
if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId);
await this.syncNativeArchiveState(record.provider, record.persistence, "restore");
await registry.upsert({
@@ -111,7 +111,7 @@ interface CodexSessionTestAccess {
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): void;
loadPersistedHistory(): Promise<void>;
loadPersistedHistory(client: CodexClientLike | null): Promise<void>;
refreshResolvedCollaborationMode(): void;
serviceTier: "fast" | null;
planModeEnabled: boolean;
@@ -124,7 +124,7 @@ interface CodexClientLike {
}
type CodexTestSession = AgentSession & {
connected: boolean;
connectionState: "disconnected" | "history-ready" | "connected";
currentThreadId: string | null;
activeForegroundTurnId: string | null;
client: CodexClientLike | null;
@@ -165,7 +165,7 @@ function createSession(
options.goalsEnabled === true,
options.autoReviewEnabled === true,
) as CodexTestSession;
session.connected = true;
session.connectionState = "connected";
session.currentThreadId = "test-thread";
session.activeForegroundTurnId = "test-turn";
return session;
@@ -1435,7 +1435,7 @@ describe("Codex app-server provider", () => {
purpose: "history",
});
expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume", "thread/read"]);
expect(threadRequests).toEqual(["thread/read"]);
await session.close();
appServer.assertNoErrors();
});
@@ -3931,7 +3931,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -3985,7 +3985,7 @@ describe("Codex app-server provider", () => {
})),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({
index: 0,
@@ -4054,7 +4054,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -4216,7 +4216,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -4281,7 +4281,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -4455,7 +4455,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -4516,7 +4516,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -3371,7 +3371,7 @@ export class CodexAppServerAgentSession implements AgentSession {
// `thread/compacted` and a completed `contextCompaction` item.
private unpairedCompactionNotificationCompletions = 0;
private unpairedCompactionItemCompletions = 0;
private connected = false;
private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected";
private connectionPromise: Promise<void> | null = null;
private closed = false;
private collaborationModes: Array<{
@@ -3444,7 +3444,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.closed) {
throw this.createClosedError();
}
if (this.connected) return;
if (this.connectionState !== "disconnected") return;
if (this.connectionPromise) {
await this.connectionPromise;
if (this.closed) {
@@ -3468,6 +3468,11 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private async establishConnection(): Promise<void> {
if (this.initialResumePurpose === "history") {
await this.readArchivedHistory();
this.connectionState = "history-ready";
return;
}
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
if (this.closed) {
@@ -3490,16 +3495,14 @@ export class CodexAppServerAgentSession implements AgentSession {
await this.loadSkills();
if (this.currentThreadId) {
await this.ensureThreadLoaded({
allowArchivedHistory: this.initialResumePurpose === "history",
});
await this.loadPersistedHistory();
await this.ensureThreadLoaded();
await this.loadPersistedHistory(this.client);
}
if (this.closed) {
throw this.createClosedError();
}
this.connected = true;
this.connectionState = "connected";
} catch (error) {
try {
if (this.client === client) {
@@ -3550,7 +3553,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private handleUnexpectedTermination(error: Error): void {
this.connected = false;
this.connectionState = "disconnected";
const hasActiveRootTurn = this.activeForegroundTurnId !== null || this.currentTurnId !== null;
this.clearPendingPermissions({ preservePlanApprovals: !hasActiveRootTurn });
if (hasActiveRootTurn) {
@@ -3782,9 +3785,20 @@ export class CodexAppServerAgentSession implements AgentSession {
);
}
private async loadPersistedHistory(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
const client = this.client;
private async readArchivedHistory(): Promise<void> {
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
await this.loadPersistedHistory(client);
} finally {
await client.dispose();
}
}
private async loadPersistedHistory(client: CodexAppServerClientLike | null): Promise<void> {
if (!client || !this.currentThreadId) return;
const threadId = this.currentThreadId;
const history = await loadCodexThreadHistoryTimeline({
@@ -3865,9 +3879,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private async ensureThreadLoaded(
options: { allowArchivedHistory?: boolean } = {},
): Promise<void> {
private async ensureThreadLoaded(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
const params: Record<string, unknown> = { threadId: this.currentThreadId };
const developerInstructions = composeSystemPromptParts(
@@ -3892,16 +3904,6 @@ export class CodexAppServerAgentSession implements AgentSession {
} catch (error) {
const threadId = this.currentThreadId;
const message = error instanceof Error ? error.message : String(error);
if (
options.allowArchivedHistory === true &&
isArchivedCodexThreadResumeError(error, threadId)
) {
this.logger.info(
{ threadId },
"Loading archived Codex thread history without resuming the native session",
);
return;
}
if (isArchivedCodexThreadResumeError(error, threadId)) {
try {
await this.client.request("thread/unarchive", { threadId });
@@ -3975,7 +3977,7 @@ export class CodexAppServerAgentSession implements AgentSession {
return expandCodexCustomPrompt(parsed.body, args);
}
if (!this.connected) {
if (this.connectionState === "disconnected") {
await this.connect();
} else {
await this.loadSkills();
@@ -4397,7 +4399,7 @@ export class CodexAppServerAgentSession implements AgentSession {
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo };
if (!this.connected) {
if (this.connectionState === "disconnected") {
await this.connect();
}
if (!this.currentThreadId) {
@@ -4770,7 +4772,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
this.persistedHistory = [];
this.historyPending = false;
await this.loadPersistedHistory();
await this.loadPersistedHistory(this.client);
this.reconcileAsyncQuestionsAfterRewind();
},
});
@@ -4859,7 +4861,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private async disposeClient(): Promise<void> {
const client = this.client;
this.connected = false;
this.connectionState = "disconnected";
this.currentTurnId = null;
if (client) {
await client.dispose();
@@ -4869,7 +4871,7 @@ export class CodexAppServerAgentSession implements AgentSession {
async listCommands(): Promise<AgentSlashCommand[]> {
const prompts = await listCodexCustomPrompts();
if (!this.connected) {
if (this.connectionState === "disconnected") {
await this.connect();
} else {
await this.loadSkills();
@@ -0,0 +1,151 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { z } from "zod";
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import type { AgentSession } from "../agent-sdk-types.js";
async function readNativeThreadPath(threadId: string): Promise<string> {
const client = new CodexAppServerClient(
spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }),
createTestLogger(),
);
try {
await client.request("initialize", {
clientInfo: { name: "paseo-archive-regression", version: "1.0.0" },
});
client.notify("initialized", {});
const response = await client.request("thread/read", { threadId });
return z.object({ thread: z.object({ path: z.string() }) }).parse(response).thread.path;
} finally {
await client.dispose();
}
}
// Real native processes and a real completion. The second case represents records
// archived by older Paseo versions whose best-effort native archive failed.
test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1").each([true, false])(
"Codex history releases its process and leaves native archive unchanged (native archived: %s)",
async (nativeArchived) => {
const cwd = await mkdtemp(path.join(tmpdir(), "codex-history-lifecycle-"));
const logger = createTestLogger();
const provider = new CodexAppServerAgentClient(logger);
const spawned: ChildProcessWithoutNullStreams[] = [];
let active: AgentSession | undefined;
let history: CodexAppServerAgentSession | undefined;
try {
const catalog = await provider.fetchCatalog({ scope: "global", force: false });
const model = catalog.models.find((entry) => entry.isDefault)!;
const config = {
provider: "codex",
cwd,
model: model.id,
modeId: "full-access",
thinkingOptionId: "low",
};
const initialProcesses: ChildProcessWithoutNullStreams[] = [];
const initial = new CodexAppServerAgentSession(config, null, logger, async () => {
const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
initialProcesses.push(child);
return child;
});
active = initial;
await initial.connect();
const reply = await active.run(
"Reply with exactly HISTORY_REMAINS_READ_ONLY. Do not use tools.",
);
expect(reply.finalText).toContain("HISTORY_REMAINS_READ_ONLY");
const handle = active.describePersistence()!;
await active.close();
active = undefined;
expect(
initialProcesses.every((child) => child.exitCode !== null || child.signalCode !== null),
).toBe(true);
if (nativeArchived) await provider.archiveNativeSession(handle);
const nativePath = await readNativeThreadPath(handle.sessionId);
expect(nativePath.includes(`${path.sep}archived_sessions${path.sep}`)).toBe(nativeArchived);
history = new CodexAppServerAgentSession(
config,
handle,
logger,
async () => {
const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
spawned.push(child);
return child;
},
{},
false,
false,
false,
undefined,
"history",
);
await history.connect();
const text: string[] = [];
for await (const event of history.streamHistory()) {
if (event.type === "timeline" && event.item.type === "assistant_message")
text.push(event.item.text);
}
expect(text.join("\n")).toContain("HISTORY_REMAINS_READ_ONLY");
expect(spawned).toHaveLength(1);
expect(
spawned.filter((child) => child.exitCode === null && child.signalCode === null),
).toEqual([]);
expect(await readNativeThreadPath(handle.sessionId)).toBe(nativePath);
// Native unarchive is possible after reading history; history held no writer.
await provider.unarchiveNativeSession(handle);
active = await provider.resumeSession(handle, config);
expect(active.describePersistence()?.sessionId).toBe(handle.sessionId);
const followUp = await active.run(
"Repeat exactly your previous assistant reply from this conversation. Do not use tools.",
);
expect(followUp.finalText).toContain("HISTORY_REMAINS_READ_ONLY");
expect(active.describePersistence()?.sessionId).toBe(handle.sessionId);
} finally {
await history?.close();
await active?.close();
await rm(cwd, { recursive: true, force: true });
}
},
120_000,
);
test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1")(
"a failed Codex history read releases its temporary process",
async () => {
const spawned: ChildProcessWithoutNullStreams[] = [];
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: tmpdir() },
{ sessionId: "00000000-0000-4000-8000-000000000001" },
createTestLogger(),
async () => {
const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
spawned.push(child);
return child;
},
{},
false,
false,
false,
undefined,
"history",
);
try {
await expect(session.connect()).rejects.toThrow();
expect(spawned).toHaveLength(1);
expect(spawned.every((child) => child.exitCode !== null || child.signalCode !== null)).toBe(
true,
);
} finally {
await session.close();
}
},
30_000,
);