Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 88 additions & 3 deletions MIGRATION_0.26_0.27.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ agent or client implementation into a connection:
notification params are available as `ctx.params`. Agent handlers use `ctx.client`
for outbound calls to the client. Client handlers use `ctx.agent` for outbound
calls to the agent.
- Long-lived connection handles also expose the peer context. `agent.connect(...)`
returns an `AgentConnection` with `connection.client`, and `client.connect(...)`
returns a `ClientConnection` with `connection.agent`.

`AgentSideConnection` and `ClientSideConnection` still exist as deprecated
compatibility wrappers, but new code should use the app API.
Expand All @@ -24,8 +27,8 @@ compatibility wrappers, but new code should use the app API.
| -------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `new AgentSideConnection((conn) => new MyAgent(conn), stream)` | `acp.agent({ name }).onRequest(...).onNotification(...).connect(stream)` |
| `new ClientSideConnection((_agent) => client, stream)` | `acp.client({ name }).onNotification(...).connectWith(stream, async ...)` |
| Store `AgentSideConnection` on your agent class | Use `ctx.client` in agent handlers |
| Store/use `ClientSideConnection` for outgoing agent calls | Use the `ctx` passed to `connectWith` |
| Store `AgentSideConnection` on your agent class | Use `ctx.client`, `connection.client`, or `agent.onConnect(...)` |
| Store/use `ClientSideConnection` for outgoing agent calls | Use the `ctx` passed to `connectWith`, or `connection.agent` |
| Return a response from an `Agent` or `Client` method | Return a response from the app request handler |
| Throw from implementation methods for JSON-RPC errors | Throw from an app handler |
| Manually create session and prompt requests | Prefer `ctx.buildSession(...).withSession(...)` for common prompt workflows |
Expand All @@ -34,6 +37,11 @@ Both `connect(...)` and `connectWith(...)` accept either a `Stream` or the app
for the other side of the connection. Use streams for production transports and
direct app connections for tests or in-process examples.

Use `connectWith(...)` for a scoped workflow where the callback owns the
connection lifetime. Use `connect(...)` when the connection should stay open
independently of one operation; the returned connection can observe closure,
close the transport, and call the peer.

## Migrating an Agent

Previously, an agent usually implemented `acp.Agent`, stored the
Expand Down Expand Up @@ -135,6 +143,51 @@ acp
.connect(stream);
```

If your agent keeps connection-scoped state or sends notifications from
background work, use the connection handle returned by `connect(...)`:

```ts
class MyAgent {
private client?: acp.AgentContext;

bindClient(client?: acp.AgentContext): void {
this.client = client;
}

async sendBackgroundUpdate(sessionId: acp.SessionId): Promise<void> {
await this.client?.notify(acp.methods.client.session.update, {
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "Still working..." },
},
});
}
}

const implementation = new MyAgent();
const app = acp.agent({ name: "my-agent" });

const connection = app.connect(stream);
implementation.bindClient(connection.client);
```

For server-owned connections, such as an app passed to `AcpServer`, register
`onConnect(...)` instead. The hook receives the same connection-scoped client
context. `AcpServer` runs the hook after the client has completed `initialize`,
so messages sent by the hook cannot replace the initialize response:

```ts
const implementation = new MyAgent();

const app = acp.agent({ name: "my-agent" }).onConnect((connection) => {
implementation.bindClient(connection.client);
connection.signal.addEventListener("abort", () => {
implementation.bindClient(undefined);
});
});
```

For JSON-RPC errors, throw from the handler:

```ts
Expand Down Expand Up @@ -232,7 +285,26 @@ const prompt = await acp
`connectWith` owns the connection lifetime for the callback. When the callback
finishes or throws, the connection is closed. If you need the connection to stay
open independently of one operation, call `connect(stream)` and keep the
returned `AcpConnection`.
returned `ClientConnection`:

```ts
const connection = acp
.client({ name: "my-client" })
.onNotification(acp.methods.client.session.update, (ctx) =>
client.sessionUpdate(ctx.params),
)
.connect(stream);

try {
await connection.agent.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {},
});
} finally {
connection.close();
await connection.closed;
}
```

All protocol paths should be absolute. That includes `cwd`,
`additionalDirectories`, file-system request paths, terminal/tool-call
Expand Down Expand Up @@ -298,6 +370,19 @@ acp.client().onRequest(acp.methods.client.session.requestPermission, (ctx) => {
Agent handler contexts include `params` and `client`. Client handler contexts
include `params` and `agent`.

Connection handles expose those same peer contexts for connection-scoped work:

```ts
const agentConnection = acp.agent({ name: "my-agent" }).connect(stream);
await agentConnection.client.notify(acp.methods.client.session.update, update);

const clientConnection = acp.client({ name: "my-client" }).connect(stream);
await clientConnection.agent.request(acp.methods.agent.session.new, {
cwd: "/workspace/project",
mcpServers: [],
});
```

The `connectWith` callback receives a `ClientContext`, usually named `ctx`,
with `request(...)` and `notify(...)` for talking to the agent:

Expand Down
144 changes: 144 additions & 0 deletions src/acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,150 @@ describe("Connection", () => {
]);
});

it("returns peer contexts from app connection handles", async () => {
const events: string[] = [];

const appAgent = createAgent({ name: "peer-handle-agent" })
.onRequest(AGENT_METHODS.initialize, (c) => {
events.push(`initialize:${c.params.protocolVersion}`);
return {
protocolVersion: c.params.protocolVersion,
agentCapabilities: { loadSession: false },
authMethods: [],
};
})
.onNotification(
"vendor/agent/notify",
(params) => params as { message: string },
(c) => {
events.push(`agent-notify:${c.params.message}`);
},
);

const appClient = createClient({ name: "peer-handle-client" })
.onRequest(CLIENT_METHODS.fs_read_text_file, (c) => {
events.push(`read:${c.params.path}`);
return { content: "client file" };
})
.onNotification(CLIENT_METHODS.session_update, (c) => {
events.push(`update:${c.params.sessionId}`);
});

const agentConnection = appAgent.connect(appClient);
try {
const readResponse = await agentConnection.client.request(
CLIENT_METHODS.fs_read_text_file,
{
sessionId: "peer-session",
path: "/peer/file.txt",
},
);
await agentConnection.client.notify(CLIENT_METHODS.session_update, {
sessionId: "peer-session",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "from connection" },
},
});

expect(readResponse.content).toBe("client file");
await vi.waitFor(() => {
expect(events).toContain("read:/peer/file.txt");
expect(events).toContain("update:peer-session");
});
} finally {
agentConnection.close();
await agentConnection.closed;
}

const clientConnection = appClient.connect(appAgent);
try {
const initializeResponse = await clientConnection.agent.request(
AGENT_METHODS.initialize,
{
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
},
);
await clientConnection.agent.notify("vendor/agent/notify", {
message: "from-client-connection",
});

expect(initializeResponse.protocolVersion).toBe(PROTOCOL_VERSION);
await vi.waitFor(() => {
expect(events).toContain(`initialize:${PROTOCOL_VERSION}`);
expect(events).toContain("agent-notify:from-client-connection");
});
} finally {
clientConnection.close();
await clientConnection.closed;
}
});

it("runs app connection hooks with peer-callable handles", async () => {
const events: string[] = [];
let agentHookConnection: unknown;
let clientHookConnection: unknown;

const appAgent = createAgent({ name: "hook-agent" })
.onConnect(async (connection) => {
agentHookConnection = connection;
events.push("agent-connect");
connection.signal.addEventListener("abort", () => {
events.push("agent-close");
});
await connection.client.notify(CLIENT_METHODS.session_update, {
sessionId: "hook-session",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "from agent hook" },
},
});
})
.onNotification(
"vendor/agent/notify",
(params) => params as { message: string },
(c) => {
events.push(`agent-notify:${c.params.message}`);
},
);

const appClient = createClient({ name: "hook-client" })
.onConnect(async (connection) => {
clientHookConnection = connection;
events.push("client-connect");
connection.signal.addEventListener("abort", () => {
events.push("client-close");
});
await connection.agent.notify("vendor/agent/notify", {
message: "from-client-hook",
});
})
.onNotification(CLIENT_METHODS.session_update, (c) => {
events.push(`update:${c.params.sessionId}`);
});

const connection = appAgent.connect(appClient);
try {
expect(agentHookConnection).toBe(connection);
await vi.waitFor(() => {
expect(clientHookConnection).toBeDefined();
expect(events).toContain("agent-connect");
expect(events).toContain("client-connect");
expect(events).toContain("update:hook-session");
expect(events).toContain("agent-notify:from-client-hook");
});
} finally {
connection.close();
await connection.closed;
}

await vi.waitFor(() => {
expect(events).toContain("agent-close");
expect(events).toContain("client-close");
});
});

it("normalizes app built-in empty-object handler responses before sending", async () => {
const appAgent = createAgent({ name: "empty-agent-responses" })
.onRequest(AGENT_METHODS.session_load, () => {})
Expand Down
Loading