From 7319316631ebcc0dcec50975582174cc84a36808 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Nov 2026 14:35:11 +0100 Subject: [PATCH] fix: keep remote creation working on older hosts and inherit foreign parents' background --- packages/client/src/daemon-client.ts | 22 +++ packages/client/src/index.ts | 50 +++--- .../agent/host-operations/index.test.ts | 149 ++++++++++++++++++ .../src/server/agent/host-operations/index.ts | 9 +- .../agent/providers/opencode/bridge.test.ts | 5 +- .../server/connected-hosts/hosts.e2e.test.ts | 9 +- public-docs/sdk/agents.md | 1 + public-docs/sdk/agents.zh-CN.md | 2 +- 8 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 packages/server/src/server/agent/host-operations/index.test.ts diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 810c90643c..7454c8448f 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2892,6 +2892,28 @@ export class DaemonClient { }); } + /** Finds one workspace by id across pages, background ones included when the host has them. */ + async findWorkspace( + workspaceId: string, + options?: { requestId?: string }, + ): Promise { + const filter = this.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}; + let cursor: string | undefined; + let requestId = options?.requestId; + do { + const result = await this.fetchWorkspaces({ + requestId, + filter, + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + const match = result.entries.find((entry) => entry.id === workspaceId); + if (match) return match; + cursor = result.pageInfo.nextCursor ?? undefined; + requestId = undefined; + } while (cursor); + return null; + } + listWorkspaceLabels( options: { sync?: { generation: string; afterSeq: number }; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 2ea2cc6dda..568c23896b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -613,12 +613,14 @@ export function createPaseoApi( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, ) => { + const serverId = daemonClient.getLastServerInfoMessage()?.serverId; + const foreignParent = + options.background === undefined ? foreignParentHandle(options.parent, serverId) : null; + const resolved = foreignParent + ? await inheritParentBackground(options, foreignParent) + : options; const agent = await daemonClient.createAgent( - toDaemonAgentCreateOptions( - options, - placement, - daemonClient.getLastServerInfoMessage()?.serverId, - ), + toDaemonAgentCreateOptions(resolved, placement, serverId), ); return createAgentHandle(agent); }; @@ -823,25 +825,7 @@ function createWorkspaceHandleFactory( let current = typeof workspace === "string" ? null : workspace; const refresh = async (options?: { requestId?: string }) => { - let cursor: string | undefined; - let requestId = options?.requestId; - // A ref addresses one workspace by id, so a background one resolves too. - const filter = daemonClient.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}; - do { - const result = await daemonClient.fetchWorkspaces({ - requestId, - filter, - page: { limit: 200, ...(cursor ? { cursor } : {}) }, - }); - const match = result.entries.find((entry) => entry.id === id); - if (match) { - current = match; - return current; - } - cursor = result.pageInfo.nextCursor ?? undefined; - requestId = undefined; - } while (cursor); - current = null; + current = await daemonClient.findWorkspace(id, options); return current; }; @@ -1070,6 +1054,24 @@ function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string { return typeof workspace === "string" ? workspace : workspace.id; } +function foreignParentHandle( + parent: PaseoAgentCreateOptions["parent"], + serverId: string | undefined, +): PaseoAgentHandle | null { + if (!parent || typeof parent === "string" || !("address" in parent)) return null; + return parent.address.serverId === serverId ? null : parent; +} + +// A host inherits background from a caller it can see. A parent on another host is invisible to +// it, so a handle passes its own value on. A bare foreign address can't; pass background then. +async function inheritParentBackground( + options: PaseoAgentCreateOptions, + parent: PaseoAgentHandle, +): Promise { + const snapshot = parent.current() ?? (await parent.refresh())?.agent; + return snapshot?.background ? { ...options, background: true } : options; +} + function encodeParent( parent: string | AgentAddress | PaseoAgentHandle, serverId?: string, diff --git a/packages/server/src/server/agent/host-operations/index.test.ts b/packages/server/src/server/agent/host-operations/index.test.ts new file mode 100644 index 0000000000..fbd492e881 --- /dev/null +++ b/packages/server/src/server/agent/host-operations/index.test.ts @@ -0,0 +1,149 @@ +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { afterEach, expect, test } from "vitest"; + +import { createHostResolver, type HostOperations } from "./index.js"; + +type Handler = ((event?: unknown) => void) | null; + +/** A connected host from before background agents: background workspaces, no background agents. */ +class OlderHostSocket { + readyState = 1; + readonly requests: Array> = []; + onopen: Handler = null; + onmessage: Handler = null; + onclose: Handler = null; + onerror: Handler = null; + private readonly createSent = Promise.withResolvers>(); + + constructor() { + queueMicrotask(() => this.onopen?.()); + } + + createRequest(): Promise> { + return this.createSent.promise; + } + + send(data: string): void { + const frame = JSON.parse(data); + if (frame.type === "hello") { + this.reply({ + type: "status", + payload: { + status: "server_info", + serverId: "srv_older", + hostname: null, + version: null, + features: { + providerUsageList: true, + providersSnapshotCwd: true, + ownedSubscriptions: true, + backgroundWorkspaces: true, + }, + }, + }); + return; + } + if (frame.type !== "session") return; + const message = frame.message; + this.requests.push(message); + if (message.type === "fetch_workspaces_request") { + this.reply({ + type: "fetch_workspaces_response", + payload: { + requestId: message.requestId, + entries: [workspace], + pageInfo: emptyPage, + }, + }); + } + if (message.type === "create_agent_request") this.createSent.resolve(message); + } + + close(): void { + this.readyState = 3; + this.onclose?.({ code: 1000, reason: "closed" }); + } + + private reply(message: object): void { + this.onmessage?.({ data: JSON.stringify({ type: "session", message }) }); + } +} + +const emptyPage = { nextCursor: null, prevCursor: null, hasMore: false }; + +const workspace = { + id: "workspace_older", + projectId: "project_older", + projectDisplayName: "Older", + projectRootPath: "/repo/older", + workspaceDirectory: "/repo/older", + projectKind: "git", + workspaceKind: "directory", + name: "older", + archivingAt: null, + status: "done", + statusEnteredAt: null, + activityAt: "2026-05-16T00:00:00.000Z", + scripts: [], + gitRuntime: null, + githubRuntime: null, +}; + +const clients: DaemonClient[] = []; +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); +}); + +async function olderHost() { + const socket = new OlderHostSocket(); + const client = new DaemonClient({ + url: "ws://older.test", + clientId: "older-host-test", + webSocketFactory: () => socket, + reconnect: { enabled: false }, + }); + clients.push(client); + await client.connect(); + return { client, socket }; +} + +function resolverFor(client: DaemonClient, callerBackground: boolean) { + return createHostResolver({ + serverId: "origin", + callerAgentId: "caller", + caller: () => ({ background: callerBackground }), + local: {} as HostOperations, + hosts: () => ({ + directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }), + use: (_serverId, action) => action(client), + }), + }); +} + +const createArgs = { + workspaceId: workspace.id, + provider: "codex/gpt-5.4", + title: "Helper", + initialPrompt: "Summarize", +}; + +test("a visible caller still creates agents on a host without background agents", async () => { + const { client, socket } = await olderHost(); + const creating = resolverFor(client, false).use("srv_older", (host) => + host.createAgent(createArgs, {} as never), + ); + creating.catch(() => {}); + + const request = await socket.createRequest(); + expect(request).toMatchObject({ workspaceId: workspace.id }); + expect(request).not.toHaveProperty("background"); +}); + +test("a background caller's remote child asks the host to update instead of turning visible", async () => { + const { client, socket } = await olderHost(); + + await expect( + resolverFor(client, true).use("srv_older", (host) => host.createAgent(createArgs, {} as never)), + ).rejects.toThrow("Update the host to use background agents."); + expect(socket.requests.map((request) => request.type)).not.toContain("create_agent_request"); +}); diff --git a/packages/server/src/server/agent/host-operations/index.ts b/packages/server/src/server/agent/host-operations/index.ts index 1a69b7a19c..f6ae774375 100644 --- a/packages/server/src/server/agent/host-operations/index.ts +++ b/packages/server/src/server/agent/host-operations/index.ts @@ -222,9 +222,7 @@ export function createHostResolver(input: { async createAgent(args) { const parsed = remoteCreate.parse(args); const { provider, model } = resolveRequiredProviderModel(parsed.provider); - const workspace = ( - await client.fetchWorkspaces({ includeBackground: true }) - ).entries.find((row) => row.id === parsed.workspaceId); + const workspace = await client.findWorkspace(parsed.workspaceId); if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`); const labels = { ...parsed.labels, @@ -243,7 +241,10 @@ export function createHostResolver(input: { featureValues: parsed.settings?.features, }, workspaceId: parsed.workspaceId, - background: inheritAgentBackground(parsed.background, input.caller()), + // Older hosts reject any background with a workspaceId, so a visible create omits it. + ...(inheritAgentBackground(parsed.background, input.caller()) + ? { background: true } + : {}), labels, initialPrompt: parsed.initialPrompt, idempotencyKey: parsed.idempotencyKey, 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 00ab1042b1..aa7acb830c 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -190,8 +190,9 @@ describe("OpenCodeBridge", () => { title: "Background child", background: true, }); - expect(agentManager.listAgents().find((agent) => agent.config.title === "Background child")) - .toMatchObject({ 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/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index aab311631b..416c6adb31 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1733,7 +1733,7 @@ test( ); test( - "a background caller's children and workspaces on B are background unless it overrides", + "a background caller's MCP and SDK children and workspaces on B are background unless it overrides", { timeout: 30000 }, async () => { const a = await daemon(); @@ -1793,6 +1793,13 @@ test( (await bApi.paseo.agents.ref(String(agentId)).refresh())?.agent; expect(await snapshot(helper.agentId)).toMatchObject({ background: true }); expect((await snapshot(visible.agentId))?.background).toBeUndefined(); + + const sdkChild = await bApi.paseo.workspaces.ref(String(hidden.workspaceId)).agents.create({ + config: { provider: "claude/claude-test" }, + title: "SDK helper", + parent, + }); + expect(sdkChild.current()).toMatchObject({ background: true }); }, ); diff --git a/public-docs/sdk/agents.md b/public-docs/sdk/agents.md index 2b685f74a0..4d9a9a5b68 100644 --- a/public-docs/sdk/agents.md +++ b/public-docs/sdk/agents.md @@ -148,6 +148,7 @@ await helper.archive(); ``` An agent created under a background `parent` is background unless you pass `background: false`. +A parent on another host passes this on only as a handle; with a bare address, pass `background`. Placing a background agent in an existing workspace or under a parent requires `features.backgroundAgents`; on an older host the SDK throws "Update the host to use background agents." before sending. diff --git a/public-docs/sdk/agents.zh-CN.md b/public-docs/sdk/agents.zh-CN.md index 837156c08c..8731ed03e2 100644 --- a/public-docs/sdk/agents.zh-CN.md +++ b/public-docs/sdk/agents.zh-CN.md @@ -139,7 +139,7 @@ console.log(result.lastMessage); await helper.archive(); ``` -在后台 `parent` 下创建的 Agent 默认也是后台 Agent,除非传入 `background: false`。把后台 Agent 放到已有工作区或某个父 Agent 下需要 `features.backgroundAgents`;在较旧的主机上,SDK 会在发送前抛出 "Update the host to use background agents."。 +在后台 `parent` 下创建的 Agent 默认也是后台 Agent,除非传入 `background: false`。位于其他主机的父 Agent 只有以句柄传入时才会传递该设置;只传地址时,请显式传入 `background`。把后台 Agent 放到已有工作区或某个父 Agent 下需要 `features.backgroundAgents`;在较旧的主机上,SDK 会在发送前抛出 "Update the host to use background agents."。 ## 请求结构化输出 {#request-structured-output}