mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 23:23:21 +00:00
fix: keep background agents hidden across cache reload and connected hosts
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user