mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 23:23:21 +00:00
fix: keep remote creation working on older hosts and inherit foreign parents' background
This commit is contained in:
@@ -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<FetchWorkspacesEntry | null> {
|
||||
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 };
|
||||
|
||||
@@ -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<PaseoAgentCreateOptions> {
|
||||
const snapshot = parent.current() ?? (await parent.refresh())?.agent;
|
||||
return snapshot?.background ? { ...options, background: true } : options;
|
||||
}
|
||||
|
||||
function encodeParent(
|
||||
parent: string | AgentAddress | PaseoAgentHandle,
|
||||
serverId?: string,
|
||||
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
onopen: Handler = null;
|
||||
onmessage: Handler = null;
|
||||
onclose: Handler = null;
|
||||
onerror: Handler = null;
|
||||
private readonly createSent = Promise.withResolvers<Record<string, unknown>>();
|
||||
|
||||
constructor() {
|
||||
queueMicrotask(() => this.onopen?.());
|
||||
}
|
||||
|
||||
createRequest(): Promise<Record<string, unknown>> {
|
||||
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");
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user