From e9dea8bebd1b30aca47198dd92d85c322e444cc2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Nov 2026 14:13:50 +0100 Subject: [PATCH] fix: keep background agents hidden across cache reload and connected hosts --- .../src/runtime/replica-cache/index.test.ts | 17 +++++ .../app/src/runtime/replica-cache/index.ts | 49 ++++++++------ .../src/server/agent/host-operations/index.ts | 20 ++++-- .../opencode/bridge-input-errors.test.ts | 2 +- .../agent/providers/opencode/bridge.test.ts | 12 ++-- .../src/server/agent/tools/paseo-tools.ts | 1 + .../server/connected-hosts/hosts.e2e.test.ts | 64 +++++++++++++++++++ 7 files changed, 135 insertions(+), 30 deletions(-) diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index d91d8e7ba4..0ea262f7cb 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -513,6 +513,23 @@ describe("ReplicaCache", () => { expect(storage.changes[0]?.deletes).toEqual([]); }); + it("keeps a background agent background across cache reopen", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const helper = { ...agent("helper"), background: true }; + const visible = agent("visible"); + writer.commitDirectoryMutations(SERVER_ID, [ + { kind: "agent", type: "upsert", id: helper.id, value: helper }, + { kind: "agent", type: "upsert", id: visible.id, value: visible }, + ]); + await writer.flush(); + + const restored = await createCache(storage).readDirectory(SERVER_ID); + + expect(restored.agents.get("helper")?.background).toBe(true); + expect(restored.agents.get("visible")?.background).toBe(false); + }); + it("round-trips identified and anonymous open turns without changing protocol status", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 9ad604c8eb..1032a663bf 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -247,6 +247,8 @@ const StoredAgentSnapshotSchema = z.strictObject({ lastError: z.string().optional(), title: z.string().nullable(), labels: z.record(z.string(), z.string()), + // Optional: clients without background_agents never received a background agent to cache. + background: z.boolean().optional(), requiresAttention: z.boolean().optional(), attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), attentionTimestamp: IsoDateSchema.nullable().optional(), @@ -612,6 +614,31 @@ function serializeAgentTurn(agent: Agent): NonNullable { }; } +function serializeAgentCapabilities( + capabilities: Agent["capabilities"], +): StoredAgent["snapshot"]["capabilities"] { + return { + supportsStreaming: capabilities.supportsStreaming, + supportsSessionPersistence: capabilities.supportsSessionPersistence, + ...(capabilities.supportsSessionListing !== undefined + ? { supportsSessionListing: capabilities.supportsSessionListing } + : {}), + supportsDynamicModes: capabilities.supportsDynamicModes, + supportsMcpServers: capabilities.supportsMcpServers, + supportsReasoningStream: capabilities.supportsReasoningStream, + supportsToolInvocations: capabilities.supportsToolInvocations, + ...(capabilities.supportsRewindConversation !== undefined + ? { supportsRewindConversation: capabilities.supportsRewindConversation } + : {}), + ...(capabilities.supportsRewindFiles !== undefined + ? { supportsRewindFiles: capabilities.supportsRewindFiles } + : {}), + ...(capabilities.supportsRewindBoth !== undefined + ? { supportsRewindBoth: capabilities.supportsRewindBoth } + : {}), + }; +} + function serializeAgent(agent: Agent): StoredAgent { const snapshot = { id: agent.id, @@ -632,26 +659,7 @@ function serializeAgent(agent: Agent): StoredAgent { }, } : {}), - capabilities: { - supportsStreaming: agent.capabilities.supportsStreaming, - supportsSessionPersistence: agent.capabilities.supportsSessionPersistence, - ...(agent.capabilities.supportsSessionListing !== undefined - ? { supportsSessionListing: agent.capabilities.supportsSessionListing } - : {}), - supportsDynamicModes: agent.capabilities.supportsDynamicModes, - supportsMcpServers: agent.capabilities.supportsMcpServers, - supportsReasoningStream: agent.capabilities.supportsReasoningStream, - supportsToolInvocations: agent.capabilities.supportsToolInvocations, - ...(agent.capabilities.supportsRewindConversation !== undefined - ? { supportsRewindConversation: agent.capabilities.supportsRewindConversation } - : {}), - ...(agent.capabilities.supportsRewindFiles !== undefined - ? { supportsRewindFiles: agent.capabilities.supportsRewindFiles } - : {}), - ...(agent.capabilities.supportsRewindBoth !== undefined - ? { supportsRewindBoth: agent.capabilities.supportsRewindBoth } - : {}), - }, + capabilities: serializeAgentCapabilities(agent.capabilities), currentModeId: agent.currentModeId, availableModes: [], pendingPermissions: [], @@ -659,6 +667,7 @@ function serializeAgent(agent: Agent): StoredAgent { ...(agent.lastError ? { lastError: agent.lastError } : {}), title: agent.title, labels: agent.labels, + ...(agent.background ? { background: true } : {}), requiresAttention: agent.requiresAttention ?? false, attentionReason: agent.attentionReason ?? null, attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null, diff --git a/packages/server/src/server/agent/host-operations/index.ts b/packages/server/src/server/agent/host-operations/index.ts index 040a94fcfd..1a69b7a19c 100644 --- a/packages/server/src/server/agent/host-operations/index.ts +++ b/packages/server/src/server/agent/host-operations/index.ts @@ -16,6 +16,11 @@ import type { ConnectedHosts } from "../../connected-hosts/index.js"; import { resolveRequiredProviderModel } from "../mcp-shared.js"; import { selectAgentListItems } from "../agent-projections.js"; import { summarizeAgentActivity } from "../activity-curator.js"; +import { + inheritAgentBackground, + inheritWorkspaceBackground, + type BackgroundCaller, +} from "../create-agent/intent.js"; import { expandUserPath } from "../../path-utils.js"; import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js"; @@ -198,6 +203,8 @@ export function createHostResolver(input: { local: HostOperations; hosts: () => ToolHosts; callerAgentId?: string; + /** The destination can't see the caller, so its background defaults resolve before routing. */ + caller: () => BackgroundCaller | null; lockedCwd?: string; }) { return { @@ -215,9 +222,9 @@ export function createHostResolver(input: { async createAgent(args) { const parsed = remoteCreate.parse(args); const { provider, model } = resolveRequiredProviderModel(parsed.provider); - const workspace = (await client.fetchWorkspaces({})).entries.find( - (row) => row.id === parsed.workspaceId, - ); + const workspace = ( + await client.fetchWorkspaces({ includeBackground: true }) + ).entries.find((row) => row.id === parsed.workspaceId); if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`); const labels = { ...parsed.labels, @@ -236,7 +243,7 @@ export function createHostResolver(input: { featureValues: parsed.settings?.features, }, workspaceId: parsed.workspaceId, - ...(parsed.background !== undefined ? { background: parsed.background } : {}), + background: inheritAgentBackground(parsed.background, input.caller()), labels, initialPrompt: parsed.initialPrompt, idempotencyKey: parsed.idempotencyKey, @@ -361,7 +368,10 @@ export function createHostResolver(input: { worktreeSlug: args.worktreeSlug as string | undefined, }, title: args.title as string | undefined, - background: args.background as boolean | undefined, + background: inheritWorkspaceBackground( + args.background as boolean | undefined, + input.caller(), + ), }); if (response.error || !response.workspace) throw new Error(response.error ?? "Workspace creation failed"); diff --git a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts index 277cad58fe..0960f9ca62 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts @@ -94,7 +94,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as const { tools } = await manifest.json(); expect( tools.find((tool: { name: string }) => tool.name === "create_agent").inputSchema.properties, - ).not.toHaveProperty("background"); + ).toHaveProperty("background"); expect( tools.find((tool: { name: string }) => tool.name === "update_schedule").inputSchema .properties, diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 468baabe14..00ab1042b1 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -181,13 +181,17 @@ describe("OpenCodeBridge", () => { expect(createDefinition.inputSchema).toMatchObject({ properties: { notifyOnFinish: { default: true } }, }); - expect(createDefinition.inputSchema.properties).not.toHaveProperty("background"); + expect(createDefinition.inputSchema.properties).toHaveProperty("background"); expect(sendDefinition.inputSchema).toMatchObject({ properties: { notifyOnFinish: { default: true } }, }); - await expect( - catalog.executeTool("create_agent", { ...createInput, background: true }), - ).rejects.toThrow("background"); + await catalog.executeTool("create_agent", { + ...createInput, + title: "Background child", + background: true, + }); + expect(agentManager.listAgents().find((agent) => agent.config.title === "Background child")) + .toMatchObject({ background: true }); const v1Module = await import(plugin.pluginUrl); const hooks = await v1Module.default({}, plugin); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index b678d6dbe6..fe2d4187b9 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -719,6 +719,7 @@ function createToolCatalog( const hostResolver = createHostResolver({ serverId: options.serverId ?? "self", callerAgentId, + caller: () => resolveCallerAgent(), lockedCwd: callerContext?.lockedCwd, hosts: () => { if (!options.hosts) throw new Error("Connected hosts are not configured"); diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 66e30a0b65..aab311631b 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1732,6 +1732,70 @@ test( }, ); +test( + "a background caller's children and workspaces on B are background unless it overrides", + { timeout: 30000 }, + async () => { + const a = await daemon(); + const b = await daemon(); + const { paseo } = await api(a); + await paseo.hosts.add({ connection: `127.0.0.1:${b.port}`, label: "Fleet B" }); + const directory = await online(paseo); + const workspace = await paseo.workspaces.create({ + source: { kind: "directory", path: a.staticDir }, + }); + const parent = await workspace.agents.create({ + config: { provider: "claude/claude-test" }, + title: "Background parent", + background: true, + }); + const mcp = new Client({ name: "background-delegation", version: "1" }); + cleanup.push(() => mcp.close()); + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`), + ), + ); + const call = async (name: string, args: Record) => { + const response = await mcp.callTool({ name, arguments: args }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return z.record(z.string(), z.unknown()).parse(response.structuredContent); + }; + const serverId = directory.hosts[0]!.serverId!; + const hidden = await call("create_workspace", { + serverId, + isolation: "local", + path: b.staticDir, + title: "Inherited workspace", + }); + const helper = await call("create_agent", { + serverId, + workspaceId: hidden.workspaceId, + title: "Inherited helper", + provider: "claude/claude-test", + initialPrompt: "Summarize", + }); + const visible = await call("create_agent", { + serverId, + workspaceId: hidden.workspaceId, + title: "Visible helper", + provider: "claude/claude-test", + initialPrompt: "Summarize", + background: false, + }); + + const bApi = await api(b); + const workspaces = await bApi.client.fetchWorkspaces({ includeBackground: true }); + expect(workspaces.entries.find((entry) => entry.id === hidden.workspaceId)).toMatchObject({ + background: true, + }); + const snapshot = async (agentId: unknown) => + (await bApi.paseo.agents.ref(String(agentId)).refresh())?.agent; + expect(await snapshot(helper.agentId)).toMatchObject({ background: true }); + expect((await snapshot(visible.agentId))?.background).toBeUndefined(); + }, +); + test( "remote agent reports, local CLI sends and automatic completion reach the same recipient", { timeout: 30000 },