Skip to content

Commit d657310

Browse files
authored
fix: Expose peer contexts from app connections (#190)
* fix: Expose peer contexts from app connections Return side-specific connection handles from AgentApp.connect and ClientApp.connect so connection-scoped code can call the peer through .client or .agent outside request handlers. Add symmetric onConnect hooks for app-owned connection setup, route connectWith through the same handles, and keep the exposed contexts backed by the shared underlying ConnectionContext. * Clean up connections after agent close
1 parent 9d37e22 commit d657310

9 files changed

Lines changed: 889 additions & 56 deletions

MIGRATION_0.26_0.27.md

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ agent or client implementation into a connection:
1414
notification params are available as `ctx.params`. Agent handlers use `ctx.client`
1515
for outbound calls to the client. Client handlers use `ctx.agent` for outbound
1616
calls to the agent.
17+
- Long-lived connection handles also expose the peer context. `agent.connect(...)`
18+
returns an `AgentConnection` with `connection.client`, and `client.connect(...)`
19+
returns a `ClientConnection` with `connection.agent`.
1720

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

40+
Use `connectWith(...)` for a scoped workflow where the callback owns the
41+
connection lifetime. Use `connect(...)` when the connection should stay open
42+
independently of one operation; the returned connection can observe closure,
43+
close the transport, and call the peer.
44+
3745
## Migrating an Agent
3846

3947
Previously, an agent usually implemented `acp.Agent`, stored the
@@ -135,6 +143,51 @@ acp
135143
.connect(stream);
136144
```
137145

146+
If your agent keeps connection-scoped state or sends notifications from
147+
background work, use the connection handle returned by `connect(...)`:
148+
149+
```ts
150+
class MyAgent {
151+
private client?: acp.AgentContext;
152+
153+
bindClient(client?: acp.AgentContext): void {
154+
this.client = client;
155+
}
156+
157+
async sendBackgroundUpdate(sessionId: acp.SessionId): Promise<void> {
158+
await this.client?.notify(acp.methods.client.session.update, {
159+
sessionId,
160+
update: {
161+
sessionUpdate: "agent_message_chunk",
162+
content: { type: "text", text: "Still working..." },
163+
},
164+
});
165+
}
166+
}
167+
168+
const implementation = new MyAgent();
169+
const app = acp.agent({ name: "my-agent" });
170+
171+
const connection = app.connect(stream);
172+
implementation.bindClient(connection.client);
173+
```
174+
175+
For server-owned connections, such as an app passed to `AcpServer`, register
176+
`onConnect(...)` instead. The hook receives the same connection-scoped client
177+
context. `AcpServer` runs the hook after the client has completed `initialize`,
178+
so messages sent by the hook cannot replace the initialize response:
179+
180+
```ts
181+
const implementation = new MyAgent();
182+
183+
const app = acp.agent({ name: "my-agent" }).onConnect((connection) => {
184+
implementation.bindClient(connection.client);
185+
connection.signal.addEventListener("abort", () => {
186+
implementation.bindClient(undefined);
187+
});
188+
});
189+
```
190+
138191
For JSON-RPC errors, throw from the handler:
139192

140193
```ts
@@ -232,7 +285,26 @@ const prompt = await acp
232285
`connectWith` owns the connection lifetime for the callback. When the callback
233286
finishes or throws, the connection is closed. If you need the connection to stay
234287
open independently of one operation, call `connect(stream)` and keep the
235-
returned `AcpConnection`.
288+
returned `ClientConnection`:
289+
290+
```ts
291+
const connection = acp
292+
.client({ name: "my-client" })
293+
.onNotification(acp.methods.client.session.update, (ctx) =>
294+
client.sessionUpdate(ctx.params),
295+
)
296+
.connect(stream);
297+
298+
try {
299+
await connection.agent.request(acp.methods.agent.initialize, {
300+
protocolVersion: acp.PROTOCOL_VERSION,
301+
clientCapabilities: {},
302+
});
303+
} finally {
304+
connection.close();
305+
await connection.closed;
306+
}
307+
```
236308

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

373+
Connection handles expose those same peer contexts for connection-scoped work:
374+
375+
```ts
376+
const agentConnection = acp.agent({ name: "my-agent" }).connect(stream);
377+
await agentConnection.client.notify(acp.methods.client.session.update, update);
378+
379+
const clientConnection = acp.client({ name: "my-client" }).connect(stream);
380+
await clientConnection.agent.request(acp.methods.agent.session.new, {
381+
cwd: "/workspace/project",
382+
mcpServers: [],
383+
});
384+
```
385+
301386
The `connectWith` callback receives a `ClientContext`, usually named `ctx`,
302387
with `request(...)` and `notify(...)` for talking to the agent:
303388

src/acp.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,150 @@ describe("Connection", () => {
824824
]);
825825
});
826826

827+
it("returns peer contexts from app connection handles", async () => {
828+
const events: string[] = [];
829+
830+
const appAgent = createAgent({ name: "peer-handle-agent" })
831+
.onRequest(AGENT_METHODS.initialize, (c) => {
832+
events.push(`initialize:${c.params.protocolVersion}`);
833+
return {
834+
protocolVersion: c.params.protocolVersion,
835+
agentCapabilities: { loadSession: false },
836+
authMethods: [],
837+
};
838+
})
839+
.onNotification(
840+
"vendor/agent/notify",
841+
(params) => params as { message: string },
842+
(c) => {
843+
events.push(`agent-notify:${c.params.message}`);
844+
},
845+
);
846+
847+
const appClient = createClient({ name: "peer-handle-client" })
848+
.onRequest(CLIENT_METHODS.fs_read_text_file, (c) => {
849+
events.push(`read:${c.params.path}`);
850+
return { content: "client file" };
851+
})
852+
.onNotification(CLIENT_METHODS.session_update, (c) => {
853+
events.push(`update:${c.params.sessionId}`);
854+
});
855+
856+
const agentConnection = appAgent.connect(appClient);
857+
try {
858+
const readResponse = await agentConnection.client.request(
859+
CLIENT_METHODS.fs_read_text_file,
860+
{
861+
sessionId: "peer-session",
862+
path: "/peer/file.txt",
863+
},
864+
);
865+
await agentConnection.client.notify(CLIENT_METHODS.session_update, {
866+
sessionId: "peer-session",
867+
update: {
868+
sessionUpdate: "agent_message_chunk",
869+
content: { type: "text", text: "from connection" },
870+
},
871+
});
872+
873+
expect(readResponse.content).toBe("client file");
874+
await vi.waitFor(() => {
875+
expect(events).toContain("read:/peer/file.txt");
876+
expect(events).toContain("update:peer-session");
877+
});
878+
} finally {
879+
agentConnection.close();
880+
await agentConnection.closed;
881+
}
882+
883+
const clientConnection = appClient.connect(appAgent);
884+
try {
885+
const initializeResponse = await clientConnection.agent.request(
886+
AGENT_METHODS.initialize,
887+
{
888+
protocolVersion: PROTOCOL_VERSION,
889+
clientCapabilities: {},
890+
},
891+
);
892+
await clientConnection.agent.notify("vendor/agent/notify", {
893+
message: "from-client-connection",
894+
});
895+
896+
expect(initializeResponse.protocolVersion).toBe(PROTOCOL_VERSION);
897+
await vi.waitFor(() => {
898+
expect(events).toContain(`initialize:${PROTOCOL_VERSION}`);
899+
expect(events).toContain("agent-notify:from-client-connection");
900+
});
901+
} finally {
902+
clientConnection.close();
903+
await clientConnection.closed;
904+
}
905+
});
906+
907+
it("runs app connection hooks with peer-callable handles", async () => {
908+
const events: string[] = [];
909+
let agentHookConnection: unknown;
910+
let clientHookConnection: unknown;
911+
912+
const appAgent = createAgent({ name: "hook-agent" })
913+
.onConnect(async (connection) => {
914+
agentHookConnection = connection;
915+
events.push("agent-connect");
916+
connection.signal.addEventListener("abort", () => {
917+
events.push("agent-close");
918+
});
919+
await connection.client.notify(CLIENT_METHODS.session_update, {
920+
sessionId: "hook-session",
921+
update: {
922+
sessionUpdate: "agent_message_chunk",
923+
content: { type: "text", text: "from agent hook" },
924+
},
925+
});
926+
})
927+
.onNotification(
928+
"vendor/agent/notify",
929+
(params) => params as { message: string },
930+
(c) => {
931+
events.push(`agent-notify:${c.params.message}`);
932+
},
933+
);
934+
935+
const appClient = createClient({ name: "hook-client" })
936+
.onConnect(async (connection) => {
937+
clientHookConnection = connection;
938+
events.push("client-connect");
939+
connection.signal.addEventListener("abort", () => {
940+
events.push("client-close");
941+
});
942+
await connection.agent.notify("vendor/agent/notify", {
943+
message: "from-client-hook",
944+
});
945+
})
946+
.onNotification(CLIENT_METHODS.session_update, (c) => {
947+
events.push(`update:${c.params.sessionId}`);
948+
});
949+
950+
const connection = appAgent.connect(appClient);
951+
try {
952+
expect(agentHookConnection).toBe(connection);
953+
await vi.waitFor(() => {
954+
expect(clientHookConnection).toBeDefined();
955+
expect(events).toContain("agent-connect");
956+
expect(events).toContain("client-connect");
957+
expect(events).toContain("update:hook-session");
958+
expect(events).toContain("agent-notify:from-client-hook");
959+
});
960+
} finally {
961+
connection.close();
962+
await connection.closed;
963+
}
964+
965+
await vi.waitFor(() => {
966+
expect(events).toContain("agent-close");
967+
expect(events).toContain("client-close");
968+
});
969+
});
970+
827971
it("normalizes app built-in empty-object handler responses before sending", async () => {
828972
const appAgent = createAgent({ name: "empty-agent-responses" })
829973
.onRequest(AGENT_METHODS.session_load, () => {})

0 commit comments

Comments
 (0)