fix: keep background agents hidden across cache reload and connected hosts

This commit is contained in:
Mohamed Boudra
2026-11-03 14:13:50 +01:00
parent f0600763e0
commit e9dea8bebd
7 changed files with 135 additions and 30 deletions
@@ -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);
+29 -20
View File
@@ -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<StoredAgent["turn"]> {
};
}
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,
@@ -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");
@@ -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,
@@ -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);
@@ -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");
@@ -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<string, unknown>) => {
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 },