diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 3f2a9b7775..dbad9586db 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -30,6 +30,13 @@ interface ProjectionOptions { title?: string | null; createdAt?: string; internal?: boolean; + /** + * Archive timestamp, supplied by a caller holding the stored record. A live + * managed agent cannot know this: an archived agent is resumed back into + * memory to serve a history read, so being in the manager is not evidence + * that it is active. Storage stays the single owner. + */ + archivedAt?: string | null; } interface RecentProviderSessionProjectionOptions { @@ -139,6 +146,7 @@ export function toAgentPayload( persistence: projectPersistenceHandleForWire(agent.persistence), title: options?.title ?? null, labels: agent.labels, + archivedAt: options?.archivedAt ?? null, ...(agent.internal ? { internal: true } : {}), }; diff --git a/packages/server/src/server/agent/archive-discovery.e2e.test.ts b/packages/server/src/server/agent/archive-discovery.e2e.test.ts new file mode 100644 index 0000000000..f8d1dbc8e0 --- /dev/null +++ b/packages/server/src/server/agent/archive-discovery.e2e.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { z } from "zod"; +import { + AgentListItemPayloadSchema, + AgentSnapshotPayloadSchema, +} from "@getpaseo/protocol/messages"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js"; +import { ClaudeAgentClient } from "./providers/claude/agent.js"; + +// Opt in to the same workflow with the installed providers instead of test clients. +const real = process.env.PASEO_ARCHIVE_REAL === "1"; +const StatusSchema = z.object({ snapshot: AgentSnapshotPayloadSchema }); +const ListSchema = z.object({ agents: z.array(AgentListItemPayloadSchema) }); + +test.each(["codex", "claude"] as const)( + "archived %s history remains archived across MCP discovery", + async (provider) => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "archive-discovery-")); + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: real + ? { + [provider]: + provider === "codex" + ? new CodexAppServerAgentClient(logger) + : new ClaudeAgentClient({ logger }), + } + : createTestAgentClients(), + pluginsEnabled: false, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const mcp = new Client({ name: "archive-discovery-test", version: "1.0.0" }); + const call = async (name: string, args: Record) => { + const result = await mcp.callTool({ name, arguments: args }); + if (result.isError) throw new Error(JSON.stringify(result.content)); + return result.structuredContent; + }; + const list = async (includeArchived: boolean) => + ListSchema.parse(await call("list_agents", { cwd, includeArchived })).agents; + const status = async (agentId: string) => + StatusSchema.parse(await call("get_agent_status", { agentId })).snapshot; + try { + await client.connect(); + await mcp.connect( + new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${daemon.port}/mcp/agents`)), + ); + const realModel = provider === "codex" ? "gpt-5.6-sol" : "haiku"; + const agent = await client.createAgent({ + config: { + provider, + cwd, + model: real ? realModel : undefined, + modeId: provider === "codex" ? "full-access" : "bypassPermissions", + }, + }); + await daemon.daemon.agentManager.runAgent( + agent.id, + "Reply with ARCHIVE_OK. Do not use tools.", + ); + expect((await list(false)).map((item) => item.id)).toContain(agent.id); + await call("archive_agent", { agentId: agent.id }); + const archivedAt = (await status(agent.id)).archivedAt; + expect(archivedAt).toEqual(expect.any(String)); + expect((await list(false)).map((item) => item.id)).not.toContain(agent.id); + + const timeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 }); + expect(timeline.entries.length).toBeGreaterThan(0); + expect.soft((await status(agent.id)).archivedAt).toBe(archivedAt); + expect.soft((await list(false)).map((item) => item.id)).not.toContain(agent.id); + expect + .soft((await list(true)).find((item) => item.id === agent.id)?.archivedAt) + .toBe(archivedAt); + const ordinary = await client.fetchAgents({ filter: { includeArchived: false } }); + expect.soft(ordinary.entries.map((entry) => entry.agent.id)).not.toContain(agent.id); + const inclusive = await client.fetchAgents({ filter: { includeArchived: true } }); + expect + .soft(inclusive.entries.find((entry) => entry.agent.id === agent.id)?.agent.archivedAt) + .toBe(archivedAt); + + await client.refreshAgent(agent.id); + expect((await status(agent.id)).archivedAt).toBeNull(); + expect((await list(false)).map((item) => item.id)).toContain(agent.id); + } finally { + await mcp.close(); + await client.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, + 120_000, +); diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts index 2f51d4a4c1..9df68dfde6 100644 --- a/packages/server/src/server/agent/directory.ts +++ b/packages/server/src/server/agent/directory.ts @@ -60,6 +60,8 @@ export class AgentDirectory { const active = await Promise.all( live.filter(matches).map((agent) => this.deps.projectLive(agent)), ); - return [...active, ...stored].filter((agent) => this.deps.isProviderVisible(agent.provider)); + return [...active, ...stored] + .filter(matches) + .filter((agent) => this.deps.isProviderVisible(agent.provider)); } } diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index 4622d7bba4..0936474255 100644 --- a/packages/server/src/server/agent/mcp-shared.ts +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -5,7 +5,7 @@ import type { AgentPermissionRequest } from "./agent-sdk-types.js"; import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; import { curateAgentActivity } from "./activity-curator.js"; import { selectItemsByProjectedLimit } from "./timeline-projection.js"; -import type { AgentStorage } from "./agent-storage.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import { serializeAgentSnapshot } from "../messages.js"; import { StoredScheduleSchema } from "@getpaseo/protocol/schedule/types"; import type { AgentProvider } from "./agent-sdk-types.js"; @@ -202,27 +202,36 @@ export function sanitizePermissionRequest( return sanitized; } -export async function resolveAgentTitle( +async function resolveAgentStorageRecord( agentStorage: AgentStorage, agentId: string, logger: Logger, -): Promise { +): Promise { try { - const record = await agentStorage.get(agentId); - return record?.title ?? null; + return (await agentStorage.get(agentId)) ?? null; } catch (error) { - logger.error({ err: error, agentId }, "Failed to load agent title"); + logger.error({ err: error, agentId }, "Failed to load agent record"); return null; } } +// Storage owns the archive timestamp, and a live snapshot cannot carry it: an +// archived agent is resumed back into memory to serve a history read (see the +// `purpose: "history"` resume in agent-loading), so presence in the manager is +// not evidence that the agent is active. Merge the stored record here, the same +// way the WebSocket path does in Session.enrichAgentPayload, rather than +// copying the value onto the managed agent, where it would be a second source +// of truth that unarchive has to remember to clear. export async function serializeSnapshotWithMetadata( agentStorage: AgentStorage, snapshot: ManagedAgent, logger: Logger, ) { - const title = await resolveAgentTitle(agentStorage, snapshot.id, logger); - return serializeAgentSnapshot(snapshot, { title }); + const record = await resolveAgentStorageRecord(agentStorage, snapshot.id, logger); + return serializeAgentSnapshot(snapshot, { + title: record?.title ?? null, + archivedAt: record?.archivedAt ?? null, + }); } export function parseDurationString(input: string): number { diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 3258c9e885..ddfe9e57f1 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -16,7 +16,7 @@ function validateStreamEventPayload(payload: unknown): AgentStreamEventPayload | export function serializeAgentSnapshot( agent: ManagedAgent, - options?: { title?: string | null }, + options?: { title?: string | null; archivedAt?: string | null }, ): AgentSnapshotPayload { return toAgentPayload(agent, options); }