Files
paseo/packages/client/src/index.test.ts
T
77798b2e46 Show and stop agent background work (#6220)
* feat(server): add an in-memory store for agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(claude): report background tasks as background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(server): track agent background work in the agent manager

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(protocol): add agent background work messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(server): serve and push agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(client): expose agent background work on agent handles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sdk): document agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): cover background shells in background work end to end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(client): resync background work subscriptions on every snapshot

subscribe() now lists the agent's background work each time the event
observation is established, so the listener starts with the current list
and catches up after a reconnect. A list answered after a newer update
is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): answer background work lists without loading dormant agents

Background work lives only in memory, so a stored agent that is not
loaded has none. The list handler now answers [] from storage instead of
resuming the agent, and still rejects unknown, internal, and archived
agents. A new test pins that old apps never receive
agent.background_work.update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): read Claude background tasks defensively

The user's claude binary can differ from the SDK typings, so a task's
description is read as unknown and an empty or missing one becomes null.
Drop the monitor kind: Claude Monitor watches report as local_bash, so
kind is "shell" or "other" today. The store now copies only the wire
fields of each input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(server): prove internal background work was emitted before checking globals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sdk): describe background work resync and feature gating

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): cover background work reported by a retired query

The daemon stops reading a Claude query it no longer owns, so work that a
retired process still had queued cannot reach the background work list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(client): let background work subscribers handle a failed list read

When the list read that follows each subscription snapshot failed, the
error was only logged and the caller got neither the list nor a failure.
subscribe() now takes an optional onError that receives it; without one
the error is still logged. The read is not retried, since the next
reconnect reads the list again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(agents): show background work as active

Keep background activity visible after a foreground turn ends while preserving independent send and interrupt behavior.

* fix(agents): protect background work subscriptions

Keep internal work out of public updates and restore observation after a subscription failure.

* feat(agents): show Codex and OpenCode background work

Keep provider-owned shell activity visible after a turn ends while leaving foreground turn controls independent.

* feat(agents): stop individual background work

Wait for provider confirmation so stopping one background item leaves sibling work and foreground turn controls independent.

* refactor(agents): name background stop parameters

* test(website): isolate browser tests from live metadata

* test(agents): clarify background stop journeys

* fix(sdk): report background work subscription failures

Report setup failures so subscribers can replace an observation that the host released.

* fix(app): restore background work after reconnect

Wait for the host handshake before selecting the retained client so an offline pane starts observing when the connection returns.

* fix(agents): retain background shells across rewind

Preserve known live shell ownership when conversation history is rewound, and revoke it when the provider runtime exits.

* fix(website): launch test seeder across platforms

Invoke Wrangler through Node so local website test startup does not depend on an executable npm command shim.

* feat(agents): clarify background task details and tracks

Keep launch descriptions and commands distinct so background work remains identifiable, and allow composer tracks to scroll when space is limited.

* fix(agents): preserve active background work during archive

Keep background-running children out of bulk cleanup and warn before an individual archive. Preserve first taps on composer tracks while the keyboard is open.

* fix(codex): retain background terminals across rewind

Keep live terminal inventories attached to their originating threads so a fork does not hide or misroute their stop actions.

* fix(codex): scope background terminal identities to threads

Prevent commands on separate threads with the same provider process ID from sharing a stop target.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-11-06 19:49:49 +07:00

2194 lines
64 KiB
TypeScript

import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, expect, test, vi } from "vitest";
import { createPaseoApi, createPaseoClient } from "./index.js";
import { DaemonClient } from "./daemon-client.js";
import type { PaseoAgent, PaseoClient, PaseoWorkspace } from "./index.js";
type FakeWebSocketHandler = (...args: unknown[]) => void;
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
readyState = 1;
sent: Array<string | ArrayBuffer | Uint8Array> = [];
onopen: FakeWebSocketHandler | null = null;
onmessage: FakeWebSocketHandler | null = null;
onclose: FakeWebSocketHandler | null = null;
onerror: FakeWebSocketHandler | null = null;
constructor(
readonly url: string,
readonly protocols?: string | string[],
) {
FakeWebSocket.instances.push(this);
}
send(data: string | ArrayBuffer | Uint8Array): void {
this.sent.push(data);
const frame = JSON.parse(String(data));
if (frame.message?.type === "subscription.release.request")
this.message(
sessionMessage({
type: "subscription.release.response",
payload: {
requestId: frame.message.requestId,
subscriptionId: frame.message.subscriptionId,
},
}),
);
}
close(): void {
this.readyState = 3;
}
open(): void {
this.onopen?.();
}
message(data: string): void {
this.onmessage?.({ data });
}
}
afterEach(() => {
vi.unstubAllGlobals();
FakeWebSocket.instances.length = 0;
});
function sessionMessage(message: object): string {
return JSON.stringify({
type: "session",
message,
});
}
function parseSentSessionMessage(data: string | ArrayBuffer | Uint8Array | undefined): {
type: string;
requestId?: string;
agentId?: string;
workspaceId?: string;
provider?: string;
providers?: string[];
cwd?: string;
config?: unknown;
draftConfig?: unknown;
filter?: unknown;
page?: unknown;
sync?: unknown;
text?: string;
} {
if (typeof data !== "string") {
throw new Error("Expected string WebSocket frame");
}
const parsed = JSON.parse(data);
return parsed.message;
}
function parseSentFrame(
data: string | ArrayBuffer | Uint8Array | undefined,
): Record<string, unknown> {
if (typeof data !== "string") {
throw new Error("Expected string WebSocket frame");
}
return JSON.parse(data);
}
async function connectClient(
features: Record<string, boolean> = {
providerUsageList: true,
providersSnapshotCwd: true,
ownedSubscriptions: true,
},
): Promise<{ client: PaseoClient; ws: FakeWebSocket }> {
const client = createPaseoClient({
url: "ws://daemon.test",
webSocketFactory: (url) => new FakeWebSocket(url),
reconnect: { enabled: false },
});
const connectPromise = client.connect();
const ws = FakeWebSocket.instances[0];
ws.open();
const hello = parseSentFrame(ws.sent.at(-1));
expect(hello).toMatchObject({
type: "hello",
clientType: "cli",
protocolVersion: 1,
});
expect(hello.clientId).toEqual(expect.stringMatching(/^paseo-sdk-/));
ws.message(
sessionMessage({
type: "status",
payload: {
status: "server_info",
serverId: "srv_sdk_test",
hostname: null,
version: null,
features,
},
}),
);
await connectPromise;
return { client, ws };
}
function acknowledgeObservation(ws: FakeWebSocket, subscriptionId: string): void {
const request = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: request.type.replace(/([._])request$/, "$1response"),
payload: {
requestId: request.requestId,
subscriptionId,
entries: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
},
}),
);
}
async function observeAgents(client: PaseoClient, ws: FakeWebSocket): Promise<string> {
const ready = client.agents.list({ subscribe: {} });
acknowledgeObservation(ws, "agents-sdk");
return (await ready).subscriptionId!;
}
function createWorkspace(input: Partial<PaseoWorkspace> = {}): PaseoWorkspace {
return {
id: "workspace_sdk",
projectId: "project_sdk",
projectDisplayName: "SDK",
projectRootPath: "/repo/sdk",
workspaceDirectory: "/repo/sdk",
projectKind: "git",
workspaceKind: "directory",
name: "sdk",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: "2026-05-16T00:00:00.000Z",
scripts: [],
gitRuntime: null,
githubRuntime: null,
...input,
};
}
function createAgent(input: Partial<PaseoAgent> = {}): PaseoAgent {
return {
id: "agent_sdk",
provider: "codex",
cwd: "/repo/sdk",
model: null,
createdAt: "2026-05-16T00:00:00.000Z",
updatedAt: "2026-05-16T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsRewindBoth: false,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
archivedAt: null,
...input,
};
}
test("createPaseoClient exposes workspace list through the daemon client", async () => {
const { client, ws } = await connectClient();
const listPromise = client.workspaces.list({
filter: { query: "sdk" },
page: { limit: 10 },
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "fetch_workspaces_request",
filter: { query: "sdk" },
page: { limit: 10 },
});
ws.message(
sessionMessage({
type: "fetch_workspaces_response",
payload: {
requestId: request.requestId,
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
}),
);
await expect(listPromise).resolves.toEqual({
requestId: request.requestId,
entries: [],
emptyProjects: [],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
});
expect(client.getConnectionState()).toEqual({ status: "connected" });
await client.close();
});
test("createPaseoApi borrows daemon capabilities without exposing connection ownership", () => {
const daemonClient = new DaemonClient({
url: "ws://daemon.test",
clientId: "borrowed-api",
reconnect: { enabled: false },
});
const paseo = createPaseoApi(daemonClient);
expect(Object.keys(paseo).sort()).toEqual([
"agents",
"config",
"dispose",
"hosts",
"observeEvents",
"projects",
"providers",
"terminals",
"workspaces",
]);
expect("connect" in paseo).toBe(false);
expect("close" in paseo).toBe(false);
expect("skills" in paseo.agents).toBe(false);
});
test("agent handles send permission responses for their agent", async () => {
const { client, ws } = await connectClient();
await client.agents.ref("agent_sdk").respondToPermission({
requestId: "permission-request",
response: {
behavior: "deny",
selectedActionId: "deny-once",
message: "Not approved",
interrupt: true,
},
});
expect(parseSentFrame(ws.sent.at(-1))).toEqual({
type: "session",
message: {
type: "agent_permission_response",
agentId: "agent_sdk",
requestId: "permission-request",
response: {
behavior: "deny",
selectedActionId: "deny-once",
message: "Not approved",
interrupt: true,
},
},
});
await client.close();
});
test("project actions list registered projects through the existing RPC", async () => {
const { client, ws } = await connectClient();
const listPromise = client.projects.list({
requestId: "projects-list-request",
sync: { generation: "daemon-generation", afterSeq: 7 },
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "project.list.request",
requestId: "projects-list-request",
sync: { generation: "daemon-generation", afterSeq: 7 },
});
ws.message(
sessionMessage({
type: "project.list.response",
payload: {
requestId: "projects-list-request",
projects: [
{
projectId: "project_sdk",
projectKey: "sdk",
projectDisplayName: "SDK",
projectCustomName: null,
projectCustomIconRevision: null,
projectIconRevision: "icon-revision",
projectRootPath: "/repo/sdk",
projectKind: "git",
syncSeq: 8,
},
],
sync: {
generation: "daemon-generation",
headSeq: 8,
mode: "changes",
removals: [],
},
},
}),
);
await expect(listPromise).resolves.toEqual({
requestId: "projects-list-request",
projects: [
{
projectId: "project_sdk",
projectKey: "sdk",
projectDisplayName: "SDK",
projectCustomName: null,
projectCustomIconRevision: null,
projectIconRevision: "icon-revision",
projectRootPath: "/repo/sdk",
projectKind: "git",
syncSeq: 8,
},
],
sync: {
generation: "daemon-generation",
headSeq: 8,
mode: "changes",
removals: [],
},
});
await client.close();
});
test("project actions subscribe to existing project updates", async () => {
const { client, ws } = await connectClient();
const updates: string[] = [];
const unsubscribe = client.projects.subscribe((update) => {
updates.push(update.kind === "upsert" ? update.project.projectDisplayName : update.projectId);
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "session.events.set_subscription.request",
events: ["project.update"],
});
acknowledgeObservation(ws, "projects-sdk");
ws.message(
sessionMessage({
type: "project.update",
payload: {
subscriptionId: "projects-sdk",
kind: "upsert",
project: {
projectId: "project_sdk",
projectKey: "sdk",
projectDisplayName: "Renamed SDK",
projectCustomName: "Renamed SDK",
projectCustomIconRevision: null,
projectIconRevision: "icon-revision",
projectRootPath: "/repo/sdk",
projectKind: "git",
syncSeq: 9,
},
generation: "daemon-generation",
seq: 9,
},
}),
);
ws.message(
sessionMessage({
type: "project.update",
payload: {
subscriptionId: "projects-sdk",
kind: "remove",
projectId: "project_removed",
generation: "daemon-generation",
seq: 10,
},
}),
);
expect(updates).toEqual(["Renamed SDK", "project_removed"]);
unsubscribe();
ws.message(
sessionMessage({
type: "project.update",
payload: {
subscriptionId: "projects-sdk",
kind: "remove",
projectId: "project_after_unsubscribe",
},
}),
);
expect(updates).toEqual(["Renamed SDK", "project_removed"]);
await client.close();
});
test("agent actions list the daemon directory without exposing the low-level client", async () => {
const { client, ws } = await connectClient();
const listedAgent = createAgent({ title: "Planner" });
const listPromise = client.agents.list({
filter: { includeArchived: false },
page: { limit: 10 },
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "fetch_agents_request",
filter: { includeArchived: false },
page: { limit: 10 },
});
ws.message(
sessionMessage({
type: "fetch_agents_response",
payload: {
requestId: request.requestId,
entries: [
{
agent: listedAgent,
project: {
projectKey: "project_sdk",
projectName: "SDK",
workspaceName: "main",
checkout: {
cwd: "/repo/sdk",
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
},
],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
}),
);
await expect(listPromise).resolves.toMatchObject({
entries: [{ agent: { id: "agent_sdk", title: "Planner" } }],
});
await client.close();
});
test("workspace handles keep identity and refresh snapshots through existing driver calls", async () => {
const { client, ws } = await connectClient();
const openedWorkspace = createWorkspace();
const openPromise = client.workspaces.open("/repo/sdk", "open-workspace-request");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "open_project_request",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "open_project_response",
payload: {
requestId: "open-workspace-request",
workspace: openedWorkspace,
error: null,
},
}),
);
const workspace = await openPromise;
expect(workspace.id).toBe("workspace_sdk");
expect(workspace.projectId).toBe("project_sdk");
expect(workspace.directory).toBe("/repo/sdk");
expect(workspace.current()).toEqual(openedWorkspace);
const refreshedWorkspace = createWorkspace({ name: "sdk refreshed" });
const refetchPromise = workspace.refresh({ requestId: "workspace-refetch-request" });
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_workspaces_request",
requestId: "workspace-refetch-request",
page: { limit: 200 },
});
ws.message(
sessionMessage({
type: "fetch_workspaces_response",
payload: {
requestId: "workspace-refetch-request",
entries: [],
pageInfo: {
nextCursor: "workspace-page-2",
prevCursor: null,
hasMore: true,
},
},
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
const secondPageRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(secondPageRequest).toMatchObject({
type: "fetch_workspaces_request",
page: { limit: 200, cursor: "workspace-page-2" },
});
ws.message(
sessionMessage({
type: "fetch_workspaces_response",
payload: {
requestId: secondPageRequest.requestId,
entries: [refreshedWorkspace],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
}),
);
await expect(refetchPromise).resolves.toEqual(refreshedWorkspace);
expect(workspace.current()).toEqual(refreshedWorkspace);
const directoryReady = client.workspaces.list({ subscribe: {} });
acknowledgeObservation(ws, "workspaces-sdk");
await directoryReady;
const updates: string[] = [];
const unsubscribe = workspace.subscribe((update) => {
if (update.kind === "upsert") {
updates.push(update.workspace.name);
}
});
const pushedWorkspace = createWorkspace({ name: "sdk pushed" });
ws.message(
sessionMessage({
type: "workspace_update",
payload: { subscriptionId: "workspaces-sdk", kind: "upsert", workspace: pushedWorkspace },
}),
);
expect(updates).toEqual(["sdk pushed"]);
expect(workspace.current()).toEqual(pushedWorkspace);
const titlePromise = workspace.setTitle("SDK review", "workspace-title-request");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "workspace.title.set.request",
requestId: "workspace-title-request",
workspaceId: "workspace_sdk",
title: "SDK review",
});
ws.message(
sessionMessage({
type: "workspace.title.set.response",
payload: {
requestId: "workspace-title-request",
workspaceId: "workspace_sdk",
accepted: true,
title: "SDK review",
error: null,
},
}),
);
await expect(titlePromise).resolves.toEqual({ title: "SDK review" });
const basePromise = workspace.setBaseBranch({
baseRef: "refs/heads/release",
requestId: "workspace-base-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "workspace.base_branch.set.request",
workspaceId: "workspace_sdk",
baseRef: "refs/heads/release",
requestId: "workspace-base-request",
});
ws.message(
sessionMessage({
type: "workspace.base_branch.set.response",
payload: {
requestId: "workspace-base-request",
workspaceId: "workspace_sdk",
accepted: true,
baseRef: "refs/heads/release",
error: null,
},
}),
);
await expect(basePromise).resolves.toEqual({ baseRef: "refs/heads/release" });
unsubscribe();
ws.message(
sessionMessage({
type: "workspace_update",
payload: {
subscriptionId: "workspaces-sdk",
kind: "upsert",
workspace: createWorkspace({ name: "sdk after unsubscribe" }),
},
}),
);
expect(updates).toEqual(["sdk pushed"]);
await client.close();
});
test("plugin-shaped PR workspace create and agent create use the existing daemon RPCs", async () => {
const { client, ws } = await connectClient();
const createdWorkspace = createWorkspace({ id: "workspace_fresh", name: "Issue 42" });
const workspacePromise = client.workspaces.create({
source: {
kind: "worktree",
cwd: "/repo/sdk",
action: "checkout",
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
},
title: "Issue 42",
});
const workspaceRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(workspaceRequest).toMatchObject({
type: "workspace.create.request",
source: {
kind: "worktree",
cwd: "/repo/sdk",
action: "checkout",
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
},
title: "Issue 42",
});
ws.message(
sessionMessage({
type: "workspace.create.response",
payload: {
requestId: workspaceRequest.requestId,
workspace: createdWorkspace,
setupTerminalId: null,
error: null,
},
}),
);
const workspace = await workspacePromise;
const agentPromise = workspace.agents.create({
config: { provider: "codex/gpt-5.4" },
parent: client.agents.ref("parent_sdk"),
prompt: "Implement issue 42.",
});
const agentRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(agentRequest).toMatchObject({
type: "create_agent_request",
config: { provider: "codex", model: "gpt-5.4", cwd: "/repo/sdk" },
workspaceId: "workspace_fresh",
callerAgentId: "parent_sdk",
initialPrompt: "Implement issue 42.",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: agentRequest.requestId,
agentId: "agent_sdk",
agent: createAgent({ workspaceId: "workspace_fresh" }),
},
}),
);
const agent = await agentPromise;
expect(agent.workspaceId).toBe("workspace_fresh");
expect(agent.cwd).toBe("/repo/sdk");
await client.close();
});
test("agents.create forwards internal on the request, outside the agent config", async () => {
const { client, ws } = await connectClient({
providerUsageList: true,
providersSnapshotCwd: true,
ownedSubscriptions: true,
backgroundWorkspaces: true,
});
const createPromise = client.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/repo/sdk",
title: "Summary helper",
background: true,
autoArchive: true,
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "create_agent_request",
config: { provider: "codex", model: "gpt-5.4", cwd: "/repo/sdk", title: "Summary helper" },
background: true,
autoArchive: true,
});
expect(request.config).not.toHaveProperty("internal");
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: request.requestId,
agentId: "agent_helper",
agent: createAgent({ id: "agent_helper" }),
},
}),
);
const agent = await createPromise;
expect(agent.id).toBe("agent_helper");
await client.close();
});
test("agents.create with internal fails before sending on a host without internal agents", async () => {
const { client, ws } = await connectClient();
const sentBefore = ws.sent.length;
await expect(
client.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/repo/sdk",
background: true,
}),
).rejects.toThrow("Update the host to use background workspaces.");
expect(ws.sent).toHaveLength(sentBefore);
await client.close();
});
async function createWorkspaceHandle(features: Record<string, boolean>) {
const { client, ws } = await connectClient(features);
const creating = client.workspaces.create({
source: { kind: "directory", path: "/repo/sdk" },
});
const request = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "workspace.create.response",
payload: {
requestId: request.requestId,
workspace: createWorkspace({ id: "workspace_open" }),
setupTerminalId: null,
error: null,
},
}),
);
return { client, ws, workspace: await creating };
}
test("a background agent in an existing workspace fails before sending on a host without background agents", async () => {
const { client, ws, workspace } = await createWorkspaceHandle({
providerUsageList: true,
providersSnapshotCwd: true,
ownedSubscriptions: true,
backgroundWorkspaces: true,
});
const sentBefore = ws.sent.length;
await expect(
workspace.agents.create({ config: { provider: "codex/gpt-5.4" }, background: true }),
).rejects.toThrow("Update the host to use background agents.");
expect(ws.sent).toHaveLength(sentBefore);
const creating = client.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/repo/sdk",
background: true,
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "create_agent_request",
background: true,
});
expect(parseSentSessionMessage(ws.sent.at(-1))).not.toHaveProperty("workspaceId");
creating.catch(() => {});
await client.close();
});
test("a background agent in an existing workspace is sent to a host with background agents", async () => {
const { client, ws, workspace } = await createWorkspaceHandle({
providerUsageList: true,
providersSnapshotCwd: true,
ownedSubscriptions: true,
backgroundWorkspaces: true,
backgroundAgents: true,
});
const creating = workspace.agents.create({
config: { provider: "codex/gpt-5.4" },
background: true,
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "create_agent_request",
workspaceId: "workspace_open",
background: true,
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: request.requestId,
agentId: "agent_background",
agent: createAgent({ id: "agent_background", workspaceId: "workspace_open" }),
},
}),
);
expect((await creating).id).toBe("agent_background");
await client.close();
});
test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => {
const { client, ws } = await connectClient();
const createdAgent = createAgent();
const createPromise = client.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/repo/sdk",
prompt: "ship it",
});
const createRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(createRequest).toMatchObject({
type: "create_agent_request",
config: {
provider: "codex",
model: "gpt-5.4",
cwd: "/repo/sdk",
},
initialPrompt: "ship it",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: createRequest.requestId,
agentId: "agent_sdk",
agent: createdAgent,
},
}),
);
const agent = await createPromise;
expect(agent.id).toBe("agent_sdk");
expect(agent.current()).toEqual(createdAgent);
await observeAgents(client, ws);
const updatedAgents: string[] = [];
const unsubscribe = agent.subscribe((update) => {
if (update.kind === "upsert") {
updatedAgents.push(update.agent.title ?? "");
}
});
const updatedAgent = createAgent({ title: "Updated" });
ws.message(
sessionMessage({
type: "agent_update",
payload: { subscriptionId: "agents-sdk", kind: "upsert", agent: updatedAgent, project: null },
}),
);
expect(updatedAgents).toEqual(["Updated"]);
expect(agent.current()).toEqual(updatedAgent);
const sendPromise = agent.send("hello", {
messageId: "message-sdk",
activeTurnBehavior: "steer",
});
const sendRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(sendRequest).toMatchObject({
type: "send_agent_message_request",
agentId: "agent_sdk",
text: "hello",
messageId: "message-sdk",
activeTurnBehavior: "steer",
});
ws.message(
sessionMessage({
type: "send_agent_message_response",
payload: {
requestId: sendRequest.requestId,
agentId: "agent_sdk",
accepted: true,
error: null,
},
}),
);
await sendPromise;
const runPromise = agent.run("finish the task", {
messageId: "run-message-sdk",
activeTurnBehavior: "interrupt",
timeoutMs: 30_000,
});
const runSendRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(runSendRequest).toMatchObject({
type: "send_agent_message_request",
agentId: "agent_sdk",
text: "finish the task",
messageId: "run-message-sdk",
activeTurnBehavior: "interrupt",
});
ws.message(
sessionMessage({
type: "send_agent_message_response",
payload: {
requestId: runSendRequest.requestId,
agentId: "agent_sdk",
accepted: true,
error: null,
},
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
const waitRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(waitRequest).toMatchObject({
type: "wait_for_finish_request",
agentId: "agent_sdk",
timeoutMs: 30_000,
});
const finishedAgent = createAgent({ title: "Finished" });
ws.message(
sessionMessage({
type: "wait_for_finish_response",
payload: {
requestId: waitRequest.requestId,
agentId: "agent_sdk",
status: "idle",
final: finishedAgent,
error: null,
lastMessage: "DONE",
},
}),
);
await expect(runPromise).resolves.toMatchObject({ status: "idle", lastMessage: "DONE" });
expect(agent.current()).toEqual(finishedAgent);
const defaultWaitPromise = agent.waitForFinish();
const defaultWaitRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(defaultWaitRequest).toMatchObject({
type: "wait_for_finish_request",
agentId: "agent_sdk",
timeoutMs: 10 * 60_000,
});
ws.message(
sessionMessage({
type: "wait_for_finish_response",
payload: {
requestId: defaultWaitRequest.requestId,
agentId: "agent_sdk",
status: "idle",
final: finishedAgent,
error: null,
lastMessage: "DONE",
},
}),
);
await expect(defaultWaitPromise).resolves.toMatchObject({ status: "idle" });
const timelineAgent = createAgent({ title: "Timeline" });
const timelinePromise = agent.timeline.refetch({ limit: 5 });
const timelineRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(timelineRequest).toMatchObject({
type: "fetch_agent_timeline_request",
agentId: "agent_sdk",
limit: 5,
});
ws.message(
sessionMessage({
type: "fetch_agent_timeline_response",
payload: {
requestId: timelineRequest.requestId,
agentId: "agent_sdk",
agent: timelineAgent,
direction: "tail",
projection: "projected",
epoch: "epoch-sdk",
reset: false,
staleCursor: false,
gap: false,
window: {
minSeq: 0,
maxSeq: 0,
nextSeq: 0,
},
startCursor: null,
endCursor: null,
hasOlder: false,
hasNewer: false,
entries: [],
error: null,
},
}),
);
await timelinePromise;
expect(agent.current()).toEqual(timelineAgent);
const appendPromise = agent.timeline.append({
type: "plugin",
id: "review-1",
kind: "review",
version: 1,
data: { status: "running" },
});
const appendRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(appendRequest).toMatchObject({
type: "agent.timeline.append.request",
agentId: "agent_sdk",
item: { id: "review-1", kind: "review" },
});
ws.message(
sessionMessage({
type: "agent.timeline.append.response",
payload: { requestId: appendRequest.requestId, seq: 8, epoch: "epoch-sdk" },
}),
);
await expect(appendPromise).resolves.toEqual({ seq: 8, epoch: "epoch-sdk" });
const archivePromise = agent.archive();
const archiveRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(archiveRequest).toMatchObject({
type: "archive_agent_request",
agentId: "agent_sdk",
});
ws.message(
sessionMessage({
type: "agent_archived",
payload: {
requestId: archiveRequest.requestId,
agentId: "agent_sdk",
archivedAt: "2026-05-16T01:00:00.000Z",
},
}),
);
await expect(archivePromise).resolves.toEqual({
archivedAt: "2026-05-16T01:00:00.000Z",
});
expect(agent.current()?.archivedAt).toBe("2026-05-16T01:00:00.000Z");
expect(agent.archivedAt).toBe("2026-05-16T01:00:00.000Z");
unsubscribe();
await client.close();
});
test("agent handles list the session's own commands through the existing daemon RPC", async () => {
const { client, ws } = await connectClient();
const agent = client.agents.ref("agent_sdk");
const commandsPromise = agent.commands({ requestId: "agent-commands-request" });
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "list_commands_request",
agentId: "agent_sdk",
requestId: "agent-commands-request",
});
ws.message(
sessionMessage({
type: "list_commands_response",
payload: {
requestId: "agent-commands-request",
agentId: "agent_sdk",
commands: [
{
name: "brainstorming",
description: "Turn an idea into a design",
argumentHint: "[topic]",
kind: "skill",
},
{
name: "usage",
description: "Show usage",
argumentHint: "",
kind: "command",
},
],
error: null,
},
}),
);
await expect(commandsPromise).resolves.toMatchObject({
agentId: "agent_sdk",
error: null,
commands: [
{ name: "brainstorming", kind: "skill" },
{ name: "usage", kind: "command" },
],
});
await client.close();
});
test("agent handles expose the observed snapshot through readonly properties", async () => {
const { client, ws } = await connectClient();
const agent = client.agents.ref("agent_sdk");
expect(agent.capabilities).toBeNull();
expect(agent.availableModes).toBeNull();
expect(agent.pendingPermissions).toBeNull();
expect(agent.activeTurn).toBeNull();
expect(agent.lastUsage).toBeNull();
expect(agent.lastError).toBeNull();
expect(agent.features).toBeNull();
expect(agent.runtimeInfo).toBeNull();
expect(agent.archivedAt).toBeNull();
const observedAgent = createAgent({
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsRewindBoth: true,
supportsRewindConversation: true,
supportsRewindFiles: true,
supportsToolInvocations: false,
},
availableModes: [{ id: "plan", label: "Plan" }],
pendingPermissions: [{ id: "permission-sdk", provider: "codex", name: "shell", kind: "tool" }],
activeTurn: { turnId: "turn-sdk", startedAt: "2026-05-16T00:05:00.000Z" },
lastUsage: { inputTokens: 120, outputTokens: 45, totalCostUsd: 0.02 },
lastError: "provider exited",
features: [{ type: "toggle", id: "web-search", label: "Web search", value: true }],
runtimeInfo: { provider: "codex", sessionId: "session-sdk", model: "gpt-5.4" },
archivedAt: "2026-05-16T02:00:00.000Z",
});
await observeAgents(client, ws);
const unsubscribe = agent.subscribe(() => {});
ws.message(
sessionMessage({
type: "agent_update",
payload: {
subscriptionId: "agents-sdk",
kind: "upsert",
agent: observedAgent,
project: null,
},
}),
);
expect(agent.capabilities).toEqual(observedAgent.capabilities);
expect(agent.availableModes).toEqual([{ id: "plan", label: "Plan" }]);
expect(agent.pendingPermissions).toEqual([
{ id: "permission-sdk", provider: "codex", name: "shell", kind: "tool" },
]);
expect(agent.activeTurn).toEqual({
turnId: "turn-sdk",
startedAt: "2026-05-16T00:05:00.000Z",
});
expect(agent.lastUsage).toEqual({ inputTokens: 120, outputTokens: 45, totalCostUsd: 0.02 });
expect(agent.lastError).toBe("provider exited");
expect(agent.features).toEqual([
{ type: "toggle", id: "web-search", label: "Web search", value: true },
]);
expect(agent.runtimeInfo).toEqual({
provider: "codex",
sessionId: "session-sdk",
model: "gpt-5.4",
});
expect(agent.archivedAt).toBe("2026-05-16T02:00:00.000Z");
unsubscribe();
await client.close();
});
test("provider actions delegate to existing provider RPCs and local snapshot updates", async () => {
const { client, ws } = await connectClient();
const modelsPromise = client.providers.listModels("codex", {
cwd: "/repo/sdk",
requestId: "provider-models-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "list_provider_models_request",
requestId: "provider-models-request",
provider: "codex",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "list_provider_models_response",
payload: {
requestId: "provider-models-request",
provider: "codex",
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }],
error: null,
fetchedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
await expect(modelsPromise).resolves.toMatchObject({
provider: "codex",
models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }],
});
const modesPromise = client.providers.listModes("codex", {
cwd: "/repo/sdk",
requestId: "provider-modes-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "list_provider_modes_request",
requestId: "provider-modes-request",
provider: "codex",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "list_provider_modes_response",
payload: {
requestId: "provider-modes-request",
provider: "codex",
modes: [{ id: "full-access", label: "Full Access" }],
error: null,
fetchedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
await expect(modesPromise).resolves.toMatchObject({
provider: "codex",
modes: [{ id: "full-access", label: "Full Access" }],
});
const featuresPromise = client.providers.listFeatures(
{
provider: "codex/gpt-5.4",
cwd: "/repo/sdk",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
},
{ requestId: "provider-features-request" },
);
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "list_provider_features_request",
requestId: "provider-features-request",
draftConfig: {
provider: "codex",
cwd: "/repo/sdk",
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { webSearch: true },
},
});
ws.message(
sessionMessage({
type: "list_provider_features_response",
payload: {
requestId: "provider-features-request",
provider: "codex",
features: [{ type: "toggle", id: "webSearch", label: "Web Search", value: true }],
error: null,
fetchedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
await expect(featuresPromise).resolves.toMatchObject({
provider: "codex",
features: [{ type: "toggle", id: "webSearch", label: "Web Search", value: true }],
});
const availablePromise = client.providers.listAvailable({
requestId: "providers-available-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "list_available_providers_request",
requestId: "providers-available-request",
});
ws.message(
sessionMessage({
type: "list_available_providers_response",
payload: {
requestId: "providers-available-request",
providers: [{ provider: "codex", available: true, error: null }],
error: null,
fetchedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
await expect(availablePromise).resolves.toMatchObject({
providers: [{ provider: "codex", available: true, error: null }],
});
const snapshotPromise = client.providers.snapshot({
cwd: "/repo/sdk",
requestId: "providers-snapshot-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "get_providers_snapshot_request",
requestId: "providers-snapshot-request",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "get_providers_snapshot_response",
payload: {
requestId: "providers-snapshot-request",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
await expect(snapshotPromise).resolves.toMatchObject({
entries: [{ provider: "codex", status: "ready", enabled: true }],
});
const readyPromise = client.providers.waitForReady({ cwd: "/repo/sdk", timeoutMs: 5_000 });
acknowledgeObservation(ws, "provider-wait");
await new Promise((resolve) => setTimeout(resolve, 0));
const readyRequest = parseSentSessionMessage(ws.sent.at(-1));
expect(readyRequest).toMatchObject({
type: "get_providers_snapshot_request",
cwd: "/repo/sdk",
});
ws.message(
sessionMessage({
type: "get_providers_snapshot_response",
payload: {
requestId: readyRequest.requestId,
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "loading", enabled: true }],
generatedAt: "2026-05-16T00:00:00.000Z",
},
}),
);
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
subscriptionId: "provider-wait",
cwd: "/repo/other",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:30.000Z",
},
}),
);
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
subscriptionId: "provider-wait",
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:01.000Z",
},
}),
);
await expect(readyPromise).resolves.toMatchObject({
requestId: readyRequest.requestId,
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:01.000Z",
});
const canonicalReadyPromise = client.providers.waitForReady({
cwd: "/repo/./sdk",
timeoutMs: 5_000,
});
acknowledgeObservation(ws, "provider-canonical-wait");
await new Promise((resolve) => setTimeout(resolve, 0));
const canonicalReadyRequest = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
subscriptionId: "provider-canonical-wait",
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
generatedAt: "2026-05-16T00:00:02.000Z",
},
}),
);
ws.message(
sessionMessage({
type: "get_providers_snapshot_response",
payload: {
requestId: canonicalReadyRequest.requestId,
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "loading", enabled: true }],
generatedAt: "2026-05-16T00:00:01.000Z",
},
}),
);
await expect(canonicalReadyPromise).resolves.toMatchObject({
requestId: canonicalReadyRequest.requestId,
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
});
const refreshPromise = client.providers.refresh({
cwd: "/repo/sdk",
providers: ["codex"],
requestId: "providers-refresh-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "refresh_providers_snapshot_request",
requestId: "providers-refresh-request",
cwd: "/repo/sdk",
providers: ["codex"],
});
ws.message(
sessionMessage({
type: "refresh_providers_snapshot_response",
payload: {
requestId: "providers-refresh-request",
acknowledged: true,
},
}),
);
await expect(refreshPromise).resolves.toEqual({
requestId: "providers-refresh-request",
acknowledged: true,
});
const diagnosticPromise = client.providers.diagnostic("codex", {
requestId: "provider-diagnostic-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "provider_diagnostic_request",
requestId: "provider-diagnostic-request",
provider: "codex",
});
ws.message(
sessionMessage({
type: "provider_diagnostic_response",
payload: {
requestId: "provider-diagnostic-request",
provider: "codex",
diagnostic: "Codex is ready.",
},
}),
);
await expect(diagnosticPromise).resolves.toEqual({
requestId: "provider-diagnostic-request",
provider: "codex",
diagnostic: "Codex is ready.",
});
const usagePromise = client.providers.listUsage({
requestId: "provider-usage-request",
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "provider.usage.list.request",
requestId: "provider-usage-request",
});
ws.message(
sessionMessage({
type: "provider.usage.list.response",
payload: {
requestId: "provider-usage-request",
fetchedAt: "2026-05-16T00:10:00.000Z",
providers: [
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "pro",
windows: [
{
id: "weekly",
label: "Weekly limit",
usedPct: 25,
remainingPct: 75,
resetsAt: "2026-05-20T00:00:00.000Z",
tone: "ok",
},
],
},
],
},
}),
);
await expect(usagePromise).resolves.toEqual({
requestId: "provider-usage-request",
fetchedAt: "2026-05-16T00:10:00.000Z",
providers: [
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "pro",
windows: [
{
id: "weekly",
label: "Weekly limit",
usedPct: 25,
remainingPct: 75,
resetsAt: "2026-05-20T00:00:00.000Z",
tone: "ok",
},
],
},
],
});
const snapshotUpdates: string[] = [];
const snapshotModelDefaults: Array<string | undefined> = [];
const unsubscribe = client.providers.subscribe((update) => {
snapshotUpdates.push(update.generatedAt);
snapshotModelDefaults.push(update.entries[0]?.models?.[0]?.defaultThinkingOptionId);
});
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "session.events.set_subscription.request",
events: ["providers_snapshot_update"],
});
acknowledgeObservation(ws, "provider-updates");
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
subscriptionId: "provider-updates",
cwd: "/repo/sdk",
entries: [
{
provider: "codex",
status: "ready",
enabled: true,
models: [
{
provider: "codex",
id: "gpt-5.5",
label: "GPT-5.5",
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
},
],
},
],
generatedAt: "2026-05-16T01:00:00.000Z",
},
}),
);
expect(snapshotUpdates).toEqual(["2026-05-16T01:00:00.000Z"]);
expect(snapshotModelDefaults).toEqual(["high"]);
unsubscribe();
await client.close();
});
test("waitForReady reads an older host on the existing connection", async () => {
const { client, ws } = await connectClient({});
const waiting = client.providers.waitForReady({ cwd: "/repo/./sdk" });
await expect
.poll(() => parseSentSessionMessage(ws.sent.at(-1)).type)
.toBe("get_providers_snapshot_request");
const request = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "get_providers_snapshot_response",
payload: {
requestId: request.requestId,
entries: [],
generatedAt: "2026-09-11T00:00:00.000Z",
},
}),
);
await expect(waiting).resolves.toMatchObject({ entries: [] });
expect(FakeWebSocket.instances).toHaveLength(1);
await client.close();
});
test("provider usage requires the advertised host capability", async () => {
const { client, ws } = await connectClient({});
const sentBeforeUsage = ws.sent.length;
await expect(client.providers.listUsage()).rejects.toThrow(
"Update the host to list provider usage.",
);
expect(ws.sent).toHaveLength(sentBeforeUsage);
await client.close();
});
test("config actions delegate to existing daemon config RPCs", async () => {
const { client, ws } = await connectClient();
const getPromise = client.config.get("config-get-request");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "get_daemon_config_request",
requestId: "config-get-request",
});
ws.message(
sessionMessage({
type: "get_daemon_config_response",
payload: {
requestId: "config-get-request",
config: {
mcp: { injectIntoAgents: true },
providers: {},
autoArchiveAfterMerge: false,
},
},
}),
);
await expect(getPromise).resolves.toEqual({
requestId: "config-get-request",
config: {
mcp: { injectIntoAgents: true },
providers: {},
browserTools: { enabled: false },
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
});
const patchPromise = client.config.patch(
{
providers: {
codex: {
enabled: false,
},
},
},
"config-patch-request",
);
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "set_daemon_config_request",
requestId: "config-patch-request",
config: {
providers: {
codex: {
enabled: false,
},
},
},
});
ws.message(
sessionMessage({
type: "set_daemon_config_response",
payload: {
requestId: "config-patch-request",
config: {
mcp: { injectIntoAgents: true },
providers: {
codex: {
enabled: false,
},
},
autoArchiveAfterMerge: false,
},
},
}),
);
await expect(patchPromise).resolves.toEqual({
requestId: "config-patch-request",
config: {
mcp: { injectIntoAgents: true },
providers: {
codex: {
enabled: false,
},
},
browserTools: { enabled: false },
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
});
await client.close();
});
test("agent config maps provider/model and provider-native options to the daemon", async () => {
const { client, ws } = await connectClient();
const createdAgent = createAgent({
model: "gpt-5.4",
currentModeId: "full-access",
});
const createPromise = client.agents.create({
config: {
provider: "codex/gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
options: {
approval_policy: "never",
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/repo/sdk"],
network_access: false,
},
},
},
cwd: "/repo/sdk",
prompt: "use configured provider",
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "create_agent_request",
config: {
provider: "codex",
cwd: "/repo/sdk",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
providerOptions: {
approval_policy: "never",
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
writable_roots: ["/repo/sdk"],
network_access: false,
},
},
},
initialPrompt: "use configured provider",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: request.requestId,
agentId: "agent_sdk",
agent: createdAgent,
},
}),
);
const agent = await createPromise;
expect(agent.current()).toEqual(createdAgent);
await client.close();
});
test("agent config requires provider/model syntax", async () => {
const { client } = await connectClient();
await expect(
client.agents.create({
config: { provider: "codex" },
cwd: "/repo/sdk",
}),
).rejects.toThrow('Expected config.provider in "provider/model" format');
await client.close();
});
test("canceled timeline handles and captured state are collectible while their API stays alive", async () => {
const source = `
import assert from "node:assert/strict";
import { createPaseoApi } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
import { DaemonClient } from ${JSON.stringify(new URL("./daemon-client.ts", import.meta.url).href)};
const drivers = [], apis = [];
let errors = 0;
async function batch(mode) {
const driver = new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: mode, reconnect: { enabled: false } });
const scope = new AbortController();
const api = createPaseoApi(driver, { signal: scope.signal });
drivers.push(driver); apis.push(api);
const refs = [], ready = [];
for (let i = 0; i < 100; i++) {
const state = { bytes: new Uint8Array(64 * 1024) };
const handle = api.agents.ref("unconnected-agent").timeline.subscribe(update => {
if (update.event.type === "error") errors++;
return state.bytes[0];
});
refs.push({ handle: new WeakRef(handle), state: new WeakRef(state) });
ready.push(handle.ready.catch(() => {}));
if (mode === "callable") handle();
if (mode === "release") await handle.release();
}
if (mode === "scope-abort") scope.abort();
if (mode === "client-close") await driver.close();
if (mode !== "active") await Promise.all(ready);
return refs;
}
const groups = {};
for (const mode of ["callable", "release", "scope-abort", "client-close", "active"]) groups[mode] = await batch(mode);
async function collect() {
for (let i = 0; i < 12; i++) { await new Promise(r => setImmediate(r)); global.gc(); }
}
const count = refs => ({ handles: refs.filter(r => r.handle.deref()).length, states: refs.filter(r => r.state.deref()).length });
await collect();
const live = Object.fromEntries(Object.entries(groups).map(([mode, refs]) => [mode, count(refs)]));
console.log(JSON.stringify({ phase: "API alive", live, errors }));
for (const api of apis) await api.dispose();
await collect();
const disposed = Object.fromEntries(Object.entries(groups).map(([mode, refs]) => [mode, count(refs)]));
console.log(JSON.stringify({ phase: "API disposed", disposed }));
for (const driver of drivers) await driver.close();
for (const mode of ["callable", "release", "scope-abort", "client-close"]) assert.deepEqual(live[mode], { handles: 0, states: 0 }, mode);
assert.deepEqual(live.active, { handles: 100, states: 100 }, "active ownership is retained");
assert.equal(errors, 100, "client shutdown reports failure; explicit cancellation does not");
for (const result of Object.values(disposed)) assert.deepEqual(result, { handles: 0, states: 0 });
`;
const env = Object.fromEntries(
Object.entries(process.env).filter(([key]) => !/^(PASEO_|EXPO_|E2E_|AGENT_BROWSER_)/.test(key)),
);
const result = await promisify(execFile)(
process.execPath,
["--expose-gc", "--import", "tsx", "--input-type=module", "-e", source],
{ env, timeout: 15000 },
);
expect(result.stdout).toContain('"phase":"API alive"');
}, 20000);
test("qualified parents preserve foreign identity without resolving an equal local agent ID", async () => {
const { client, ws } = await connectClient();
const localParent = client.agents.ref("same-parent");
expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" });
const creating = client.agents.create({
config: { provider: "codex/gpt-5.4" },
cwd: "/repo/sdk",
parent: { serverId: "srv_parent_elsewhere", agentId: "same-parent" },
labels: { task: "preserved" },
});
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).not.toHaveProperty("callerAgentId");
expect(request).toMatchObject({
labels: {
task: "preserved",
"paseo.parent-agent-id": "same-parent",
"paseo.parent-server-id": "srv_parent_elsewhere",
},
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_created",
requestId: request.requestId,
agentId: "agent_sdk",
agent: createAgent(),
},
}),
);
await creating;
await client.close();
expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" });
});
test("agent reload reopens through the daemon RPC then returns the observed snapshot; refresh remains a read", async () => {
const { client, ws } = await connectClient();
try {
const agent = client.agents.ref("agent_sdk");
const snapshot = createAgent();
const reloaded = agent.reload({ requestId: "reload-sdk" });
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "refresh_agent_request",
agentId: "agent_sdk",
requestId: "reload-sdk",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "reload-sdk",
agentId: "agent_sdk",
timelineSize: 0,
},
}),
);
await expect
.poll(() => parseSentSessionMessage(ws.sent.at(-1)).type)
.toBe("fetch_agent_request");
const fetched = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: fetched.requestId,
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await reloaded).toEqual({ agent: snapshot, project: null });
expect(agent.current()).toEqual(snapshot);
const read = agent.refresh("snapshot-read");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_agent_request",
requestId: "snapshot-read",
});
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: "snapshot-read",
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await read).toEqual({ agent: snapshot, project: null });
} finally {
await client.close();
}
});
const BACKGROUND_WORK_FEATURES = { ownedSubscriptions: true, agentBackgroundWork: true };
const SHELL_ITEM = {
id: "bash-1",
kind: "shell",
description: "sleep 20",
startedAt: "2026-10-06T10:00:00.000Z",
};
function answerBackgroundWorkList(ws: FakeWebSocket, items: unknown[]): void {
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({ type: "agent.background_work.list.request", agentId: "agent-a" });
ws.message(
sessionMessage({
type: "agent.background_work.list.response",
payload: { requestId: request.requestId, agentId: "agent-a", items, error: null },
}),
);
}
test("agent handles list background work", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const pending = client.agents.ref("agent-a").backgroundWork.list();
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({ type: "agent.background_work.list.request", agentId: "agent-a" });
ws.message(
sessionMessage({
type: "agent.background_work.list.response",
payload: {
requestId: request.requestId,
agentId: "agent-a",
items: [SHELL_ITEM],
error: null,
},
}),
);
await expect(pending).resolves.toEqual([SHELL_ITEM]);
await client.close();
});
test("agent handles follow only their own background work", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const received: unknown[] = [];
const unsubscribe = client.agents
.ref("agent-a")
.backgroundWork.subscribe((items) => received.push(items));
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "session.events.set_subscription.request",
events: ["agent.background_work.update"],
});
acknowledgeObservation(ws, "background-work-sdk");
answerBackgroundWorkList(ws, []);
await vi.waitFor(() => expect(received).toEqual([[]]));
ws.message(
sessionMessage({
type: "agent.background_work.update",
payload: { subscriptionId: "background-work-sdk", agentId: "agent-b", items: [SHELL_ITEM] },
}),
);
ws.message(
sessionMessage({
type: "agent.background_work.update",
payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [SHELL_ITEM] },
}),
);
await vi.waitFor(() => expect(received).toEqual([[], [SHELL_ITEM]]));
unsubscribe();
ws.message(
sessionMessage({
type: "agent.background_work.update",
payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [] },
}),
);
expect(received).toEqual([[], [SHELL_ITEM]]);
await client.close();
});
test("background work subscriptions list the current work again after a reconnect", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const received: unknown[] = [];
client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items));
acknowledgeObservation(ws, "background-work-first");
answerBackgroundWorkList(ws, [SHELL_ITEM]);
await vi.waitFor(() => expect(received).toEqual([[SHELL_ITEM]]));
ws.readyState = 3;
ws.onclose?.({ code: 1006, reason: "Connection lost" });
const reconnecting = client.connect();
const next = FakeWebSocket.instances[1];
next.open();
next.message(
sessionMessage({
type: "status",
payload: {
status: "server_info",
serverId: "srv_sdk_test",
hostname: null,
version: null,
features: BACKGROUND_WORK_FEATURES,
},
}),
);
await reconnecting;
await vi.waitFor(() =>
expect(parseSentSessionMessage(next.sent.at(-1))).toMatchObject({
type: "session.events.set_subscription.request",
}),
);
acknowledgeObservation(next, "background-work-second");
answerBackgroundWorkList(next, []);
await vi.waitFor(() => expect(received).toEqual([[SHELL_ITEM], []]));
await client.close();
});
test("background work subscriptions keep an update that overtakes their list", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const received: unknown[] = [];
client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items));
acknowledgeObservation(ws, "background-work-sdk");
await vi.waitFor(() =>
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "agent.background_work.list.request",
}),
);
const listRequest = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "agent.background_work.update",
payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [] },
}),
);
ws.message(
sessionMessage({
type: "agent.background_work.list.response",
payload: {
requestId: listRequest.requestId,
agentId: "agent-a",
items: [SHELL_ITEM],
error: null,
},
}),
);
await vi.waitFor(() => expect(received).toEqual([[]]));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(received).toEqual([[]]);
await client.close();
});
test("background work subscriptions report a failed setup to onError", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const received: unknown[] = [];
const errors: unknown[] = [];
client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items), {
onError: (error) => errors.push(error),
});
const request = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "session.events.set_subscription.response",
payload: { requestId: request.requestId },
}),
);
await vi.waitFor(() => expect(errors).toHaveLength(1));
expect((errors[0] as Error).message).toContain("subscription ID");
expect(received).toEqual([]);
await client.close();
});
test("background work subscriptions report a failed list to onError", async () => {
const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES);
const received: unknown[] = [];
const errors: unknown[] = [];
client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items), {
onError: (error) => errors.push(error),
});
acknowledgeObservation(ws, "background-work-sdk");
await vi.waitFor(() =>
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "agent.background_work.list.request",
}),
);
const listRequest = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "agent.background_work.list.response",
payload: { requestId: listRequest.requestId, agentId: "agent-a", items: [], error: "boom" },
}),
);
await vi.waitFor(() => expect(errors).toHaveLength(1));
expect(errors[0]).toBeInstanceOf(Error);
expect((errors[0] as Error).message).toContain("boom");
expect(received).toEqual([]);
await client.close();
});
test("background work asks for a host update when the daemon lacks it", async () => {
const { client } = await connectClient({ ownedSubscriptions: true });
const handle = client.agents.ref("agent-a");
await expect(handle.backgroundWork.list()).rejects.toThrow("Update the host");
expect(() => handle.backgroundWork.subscribe(() => {})).toThrow("Update the host");
await client.close();
});
test("background stop sends the live item token and waits for the response", async () => {
const { client, ws } = await connectClient({
...BACKGROUND_WORK_FEATURES,
agentBackgroundWorkStop: true,
});
const item = { ...SHELL_ITEM, canStop: true, stopToken: "generation-1" };
const pending = client.agents.ref("agent-a").backgroundWork.stop(item);
const request = parseSentSessionMessage(ws.sent.at(-1));
expect(request).toMatchObject({
type: "agent.background_work.stop.request",
agentId: "agent-a",
itemId: item.id,
stopToken: item.stopToken,
});
ws.message(
sessionMessage({
type: "agent.background_work.stop.response",
payload: {
requestId: request.requestId,
agentId: "agent-a",
itemId: item.id,
error: "rejected",
},
}),
);
await expect(pending).rejects.toThrow("rejected");
await client.close();
});
test("background stop rejects when the host does not advertise the operation", async () => {
const { client } = await connectClient(BACKGROUND_WORK_FEATURES);
await expect(
client.agents
.ref("agent-a")
.backgroundWork.stop({ ...SHELL_ITEM, canStop: true, stopToken: "generation" }),
).rejects.toThrow("Update the host");
await client.close();
});