lib/acp/src/client.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 //! The client side of one conversation with a coding agent: the options that start the agent, the
2 //! callbacks and lists that let the caller watch and steer it, and the calls that run the
3 //! conversation. A caller fills in the options, starts the agent, opens a session, sends a prompt
4 //! for each request, and ends the agent when done.
5 //!
6 //! Each call has to return the agent's answer to that call, while every message the agent sends
7 //! along the way reaches the caller's record in order and every permission request gets an answer.
8 //!
9 //! The agent mixes progress messages and requests of its own into the stream before the response a
10 //! call waits for. The agent can stream more reply text than the caller wants to hold.
11 //!
12 //! The calls are `Client.init`, `start`, the prompt calls and `deinit`, and they run one at a time:
13 //! each call reads until its own response arrives. While it waits, the call answers permission
14 //! requests, answers any other request from the agent with a "method not found" error, and drops
15 //! other notifications, and anything else fails the call with `error.AgentProtocolError`. Each
16 //! update goes to a set of callbacks the caller supplies (the _observer_) before its text joins the
17 //! reply. The callbacks receive the exact line the agent sent (the _envelope_), trimmed of
18 //! surrounding whitespace, and it stays valid only while the call runs. The client numbers the
19 //! updates from 1 in arrival order (the _update sequence_) for its whole life, and stamps each with
20 //! the caller's number for this client. When one more chunk would push the reply past a limit, the
21 //! reply keeps what came before it, the result records which update was left out, and the client
22 //! sends one `session/cancel` and keeps waiting for the agent's final response. For a permission
23 //! request the observer may answer first, and the decision reaches the observer before the answer
24 //! reaches the agent. When the observer gives no answer, the client checks two caller lists, tool
25 //! kinds and tool-title prefixes (the _permission policy_), and rejects what neither list allows.
26 //! The client starts the agent in a process group of its own, and cancel and teardown signal the
27 //! whole group to terminate and then to die.
28 //!
29 //! - *transport epoch*: a nonzero number the caller picks for one client.
30 //! - *overflow*: a record that a reply stopped at a limit.
31 const std = @import("std");
32 const sys = @import("sys");
33 const pretty = @import("pretty");
34 const json = @import("json.zig");
35 const protocol = @import("protocol.zig");
36 const reader = @import("reader/root.zig");
37
38 const pretty_json = pretty.json;
39 const test_transfer_limits: TransferLimits = .{
40 .request_bytes = 1024 * 1024,
41 .response_bytes = 1024 * 1024,
42 .update_bytes = 1024 * 1024,
43 .update_count = 1024,
44 .response_segment_count = 1024,
45 };
46
47 const TestJournal = struct {
48 update_count: usize = 0,
49 permission_count: usize = 0,
50 update_kinds: [16]protocol.UpdateKind = undefined,
51 update_sequences: [16]u64 = undefined,
52 update_text: [16][128]u8 = undefined,
53 update_text_lengths: [16]usize = undefined,
54 permission: PermissionSnapshot = .{},
55
56 const PermissionSnapshot = struct {
57 session_id: [64]u8 = undefined,
58 session_id_len: usize = 0,
59 tool_call_id: [64]u8 = undefined,
60 tool_call_id_len: usize = 0,
61 title: [128]u8 = undefined,
62 title_len: usize = 0,
63 tool_kind: [64]u8 = undefined,
64 tool_kind_len: usize = 0,
65 status: [64]u8 = undefined,
66 status_len: usize = 0,
67 outcome: [64]u8 = undefined,
68 outcome_len: usize = 0,
69 option_id: [64]u8 = undefined,
70 option_id_len: usize = 0,
71 option_kind: [64]u8 = undefined,
72 option_kind_len: usize = 0,
73 options: usize = 0,
74 };
75
76 fn observer(self: *TestJournal) Observer {
77 return .{
78 .ptr = self,
79 .updateFn = onUpdate,
80 .permissionCommitFn = onPermission,
81 };
82 }
83
84 fn onUpdate(context: *anyopaque, update: protocol.Update) !void {
85 const self: *TestJournal = @ptrCast(@alignCast(context));
86 if (self.update_count == self.update_kinds.len or
87 update.text.len > self.update_text[0].len)
88 {
89 return error.TestJournalCapacity;
90 }
91 const index = self.update_count;
92 self.update_kinds[index] = update.kind;
93 self.update_sequences[index] = update.update_sequence;
94 @memcpy(self.update_text[index][0..update.text.len], update.text);
95 self.update_text_lengths[index] = update.text.len;
96 self.update_count += 1;
97 }
98
99 fn onPermission(context: *anyopaque, request: protocol.PermissionRequest) !void {
100 const self: *TestJournal = @ptrCast(@alignCast(context));
101 self.permission_count += 1;
102 try copyTestField(&self.permission.session_id, &self.permission.session_id_len, request.session_id);
103 try copyTestField(&self.permission.tool_call_id, &self.permission.tool_call_id_len, request.tool_call_id);
104 try copyTestField(&self.permission.title, &self.permission.title_len, request.title);
105 try copyTestField(&self.permission.tool_kind, &self.permission.tool_kind_len, request.tool_kind);
106 try copyTestField(&self.permission.status, &self.permission.status_len, request.status);
107 try copyTestField(&self.permission.outcome, &self.permission.outcome_len, request.outcome);
108 try copyTestField(&self.permission.option_id, &self.permission.option_id_len, request.option_id);
109 try copyTestField(&self.permission.option_kind, &self.permission.option_kind_len, request.option_kind);
110 self.permission.options = request.options;
111 }
112 };
113
114 fn copyTestField(destination: []u8, length: *usize, source: []const u8) !void {
115 if (source.len > destination.len) return error.TestJournalCapacity;
116 @memcpy(destination[0..source.len], source);
117 length.* = source.len;
118 }
119 /// A caller names the calling program to the agent, which reads these strings in the first message
120 /// of the conversation. The structure holds the name, title and version the client reports about
121 /// itself when it opens the conversation. The call `start` sends them in the `clientInfo` object of
122 /// the `initialize` request. Every field has a default, so `.{}` reports the name "acp" and version
123 /// "1.0.0". The strings are borrowed and must stay valid while `start` can run.
124 pub const ClientInfo = struct {
125 /// The program's name, sent as `clientInfo.name`. The field defaults to "acp".
126 name: []const u8 = "acp",
127 /// The program's title, sent as `clientInfo.title`. The field defaults to "acp".
128 title: []const u8 = "acp",
129 /// The program's version, sent as `clientInfo.version`. The field defaults to "1.0.0".
130 version: []const u8 = "1.0.0",
131 };
132
133 /// A caller caps what the client writes to the agent and what it keeps from the agent, because the
134 /// agent is another program and can send without end. The structure sets five caps: the size of
135 /// each outgoing message, the size of each incoming update, the number of updates, and the size and
136 /// chunk count of one prompt's reply. Every cap must be above zero, and `Client.init` returns
137 /// `error.InvalidLimits` otherwise. `Options.transfer_limits` takes it and has no default. The
138 /// reader's line limit in `Options.reader_limits` is separate and applies to every incoming line
139 /// first.
140 pub const TransferLimits = struct {
141 /// The largest message the client writes to the agent, in bytes, counting the newline that ends
142 /// it. The limit applies to every message the client writes: `initialize`, `session/new`,
143 /// prompts, permission answers, error answers and `session/cancel`. A larger message fails the
144 /// call with `error.RequestCapacityExceeded` and nothing is written.
145 request_bytes: usize,
146 /// The most reply text, in bytes, one prompt keeps. The chunk that would pass it stays out of
147 /// the reply, and the client sends `session/cancel`. The result then holds an overflow, and its
148 /// kind is `response_bytes`. The limit counts again from zero for each prompt.
149 response_bytes: usize,
150 /// The largest update message the client accepts, in bytes of the line after surrounding
151 /// whitespace is trimmed. A larger update fails the call with `error.UpdateCapacityExceeded`
152 /// before the observer sees it.
153 update_bytes: usize,
154 /// The most update messages one client accepts over its whole life. The update sequence starts
155 /// at 1 and never resets, so the first update numbered above this cap fails the call with
156 /// `error.UpdateCountCapacityExceeded`.
157 update_count: u64,
158 /// The most agent message chunks one prompt's reply keeps. The chunk past it stays out of the
159 /// reply, and the client sends `session/cancel`. The result then holds an overflow, and its
160 /// kind is `response_segments`.
161 response_segment_count: usize,
162
163 fn valid(self: TransferLimits) bool {
164 return self.request_bytes != 0 and self.response_bytes != 0 and
165 self.update_bytes != 0 and self.update_count != 0 and
166 self.response_segment_count != 0;
167 }
168 };
169
170 /// A caller states in one value passed to `Client.init` which program to start and how, and what
171 /// limits, numbering and callbacks the conversation runs under. The structure provides the settings
172 /// `Client.init` takes: the program and its arguments, directory and environment, the limits on
173 /// reading and traffic, the caller's number for this client, and the caller's callbacks and lists.
174 /// `command`, `reader_limits`, `transfer_limits` and `transport_epoch` have no default.
175 /// `Client.init` copies the working directory and the list of arguments. The client keeps every
176 /// other string, slice and the observer by reference, so each must stay valid while the client can
177 /// use it.
178 pub const Options = struct {
179 /// The program to run. The string becomes the first entry of the child's argument list, and the
180 /// field is required.
181 command: []const u8,
182 /// The longest line, in bytes, the client accepts from the agent. `Client.init` allocates the
183 /// reader's buffer, this limit plus one byte, before the agent starts.
184 /// `acp.default_reader_limits` sets 2 MiB. A longer line fails the call with
185 /// `error.ReaderMessageCapacityExceeded`, and the client reads nothing more from the agent.
186 reader_limits: reader.Limits,
187 /// The caps on traffic, described at `TransferLimits`. Every cap must be above zero.
188 transfer_limits: TransferLimits,
189 /// A number the caller picks for this client, stamped on every update it delivers. The value
190 /// must be nonzero, and `Client.init` returns `error.InvalidLimits` otherwise. Together with
191 /// the update sequence, this number names each update the client delivers. The caller records
192 /// each change of this number before starting the client.
193 transport_epoch: u64,
194 /// The arguments passed to the program after the command. The list defaults to none, and
195 /// `Client.init` reads it only while starting the program.
196 args: []const []const u8 = &.{},
197 /// The directory the agent runs in. `start` also names it to the agent in `session/new`. Null,
198 /// the default, leaves the agent in the caller's working directory, and `start` then sends the
199 /// caller's current directory. `Client.init` copies the string.
200 cwd: ?[]const u8 = null,
201 /// The name, title and version reported to the agent, described at `ClientInfo`. The field
202 /// defaults to the `ClientInfo` defaults.
203 client: ClientInfo = .{},
204 /// The MCP servers the agent is asked to start for the session, sent in `session/new` as
205 /// `mcpServers`. The slice defaults to none. The slice is borrowed, and the client reads it
206 /// each time `start` runs.
207 mcp_servers: []const McpServer = &.{},
208 /// The lists the client checks to answer a permission request when the observer gives no
209 /// answer. The default lists nothing, so every such request gets a reject option or the
210 /// `cancelled` outcome. The lists are borrowed for the client's life.
211 permission_policy: PermissionPolicy = .{},
212 /// The caller's callbacks for updates and permission decisions, described at `Observer`. The
213 /// field defaults to null. With no observer, the first update or permission request fails the
214 /// call with `error.MissingDurableObserver`.
215 observer: ?Observer = null,
216 /// The environment the agent starts with. Null, the default, passes the caller's current
217 /// environment.
218 environ: ?std.process.Environ = null,
219 };
220
221 /// A caller gives an MCP server its own environment, one variable at a time. The structure holds
222 /// one environment variable, a name and a value, for an MCP server the agent starts. The client
223 /// writes it as a `name` and `value` object in the server's `env` list in `session/new`. The
224 /// variable sets nothing in the agent's own environment, which comes from `Options.environ`.
225 pub const EnvVariable = struct {
226 /// The variable's name, sent as `name`.
227 name: []const u8,
228 /// The variable's value, sent as `value`.
229 value: []const u8,
230 };
231
232 /// A caller gives the agent extra tools for the session, which the agent gets by starting the
233 /// listed tool servers. The structure describes one MCP (Model Context Protocol) server the agent
234 /// is asked to start, given as a name, a command, arguments and environment. `start` sends each one
235 /// in the `mcpServers` list of `session/new` as an object with `name`, `command`, `args` and `env`.
236 /// The package test writes a server that talks over standard input and output.
237 pub const McpServer = struct {
238 /// The server's name, sent as `name`.
239 name: []const u8,
240 /// The program the agent runs for this server, sent as `command`.
241 command: []const u8,
242 /// The server's arguments, sent as the `args` list. The field defaults to none.
243 args: []const []const u8 = &.{},
244 /// Environment variables for the server, sent as the `env` list. The field defaults to none.
245 env: []const EnvVariable = &.{},
246 };
247
248 /// An observer that decides a permission request itself returns this value, so the client sends
249 /// that decision to the agent. The structure holds the answer to one request from the agent for
250 /// permission to run a tool call. The client copies each string before it uses it. The client sends
251 /// the outcome and the option id to the agent, and records all three fields in the request the
252 /// observer commits. The client does not check the option id against the options the agent offered.
253 pub const PermissionReply = struct {
254 /// The outcome sent to the agent: the client uses `selected` when it picks an option and
255 /// `cancelled` when it picks none. The field is required.
256 outcome: []const u8,
257 /// The id of the chosen option, from the options the agent offered. The client sends it as
258 /// `optionId`, and leaves it out when empty. The field defaults to empty.
259 option_id: []const u8 = "",
260 /// The kind of the chosen option, such as `allow_once` or `reject_once`. The string is kept in
261 /// the committed record and never sent to the agent. The field defaults to empty.
262 option_kind: []const u8 = "",
263 };
264
265 /// The caller keeps the lasting record of the conversation and may answer permission requests
266 /// itself, so the client calls out to it before it acts on each message. The structure provides a
267 /// context pointer and three optional callbacks. The client calls them from inside `start` and the
268 /// prompt calls, in the order the messages arrive. Each update goes to `updateFn` before its text
269 /// joins the reply and before the client reads the next line. For a permission request,
270 /// `permissionFn` may answer first, and `permissionCommitFn` then receives the decision before the
271 /// client sends the answer to the agent. An error from `updateFn` or `permissionCommitFn` fails the
272 /// client call that read the message. When `updateFn` or `permissionCommitFn` is null, the first
273 /// message that needs it fails the call with `error.MissingDurableObserver`. The package README
274 /// asks the observer to commit each exact update line before it returns.
275 pub const Observer = struct {
276 /// The caller's context, passed as the first argument to every callback.
277 ptr: *anyopaque,
278 /// A callback called once per update, in arrival order, with the update's numbers, its exact
279 /// line and the fields read from it. The client frees the update and every string in it when
280 /// the call returns, so the callback copies what it keeps. Null, the default, fails the call
281 /// with `error.MissingDurableObserver` at the first update.
282 updateFn: ?*const fn (*anyopaque, protocol.Update) anyerror!void = null,
283 /// A callback asked first when the agent requests permission: a returned reply is used, and
284 /// null leaves the decision to the permission policy. The callback sees the request with
285 /// outcome `pending`, empty option fields and an empty line. Null, the default, leaves every
286 /// decision to the permission policy.
287 permissionFn: ?*const fn (*anyopaque, protocol.PermissionRequest) ?PermissionReply = null,
288 /// A callback called with the full request, the decision and the exact line, before the client
289 /// sends the answer to the agent. An error stops the answer from being sent and fails the call.
290 /// The client frees the request when the call returns, so the callback copies what it keeps.
291 /// Null, the default, fails the call with `error.MissingDurableObserver` at the first
292 /// permission request.
293 permissionCommitFn: ?*const fn (*anyopaque, protocol.PermissionRequest) anyerror!void = null,
294
295 fn update(self: Observer, value: protocol.Update) !void {
296 const callback = self.updateFn orelse return error.MissingDurableObserver;
297 try callback(self.ptr, value);
298 }
299
300 fn permission(self: Observer, request: protocol.PermissionRequest) ?PermissionReply {
301 const callback = self.permissionFn orelse return null;
302 return callback(self.ptr, request);
303 }
304
305 fn commitPermission(self: Observer, request: protocol.PermissionRequest) !void {
306 const callback = self.permissionCommitFn orelse
307 return error.MissingDurableObserver;
308 try callback(self.ptr, request);
309 }
310 };
311
312 /// A caller that trusts some tools lists them here, so the client grants those requests without
313 /// asking the observer. The client checks two lists when the observer leaves a request unanswered:
314 /// tool kinds and tool-title prefixes. A request passes when its tool call's kind equals a listed
315 /// kind, or its title starts with a listed prefix. For a request that passes, the client picks the
316 /// first `allow_once` option, else the first `allow_always` option. Otherwise, or when allow
317 /// options are absent, the client picks the first `reject_once` option, else the last
318 /// `reject_always` option, else answers `cancelled`. The default lists nothing, so it rejects every
319 /// request.
320 pub const PermissionPolicy = struct {
321 /// Tool kinds to grant, each compared whole against the tool call's `kind`. The package test
322 /// grants `execute`. The field defaults to none.
323 allowed_tool_kinds: []const []const u8 = &.{},
324 /// Title prefixes to grant, each compared against the start of the tool call's `title`. The
325 /// package test grants an MCP tool call titled `mcp__fixture__tool`, which carries no kind,
326 /// with the prefix `mcp__fixture__`. The field defaults to none.
327 allowed_title_prefixes: []const []const u8 = &.{},
328
329 fn allows(self: PermissionPolicy, object: std.json.ObjectMap) bool {
330 const params = json.objectObject(object, "params") orelse return false;
331 const tool_call = json.objectObject(params, "toolCall") orelse return false;
332 if (json.objectString(tool_call, "kind")) |kind| {
333 for (self.allowed_tool_kinds) |allowed| {
334 if (std.mem.eql(u8, allowed, kind)) return true;
335 }
336 }
337 if (json.objectString(tool_call, "title")) |title| {
338 for (self.allowed_title_prefixes) |prefix| {
339 if (std.mem.startsWith(u8, title, prefix)) return true;
340 }
341 }
342 return false;
343 }
344 };
345
346 /// A caller owns a client for the life of one agent program, to send it prompts and get replies
347 /// back. The instance holds one agent process, the pipes to it, the reader for its output, and the
348 /// state of one session. The order of use is `init`, `start`, any number of prompt calls, then
349 /// `deinit`. The client writes each message to the agent as one line of JSON and reads the agent's
350 /// messages one line at a time. Each call writes one request and then reads the agent's messages
351 /// until the matching response arrives. While it waits, the call hands updates to the observer,
352 /// answers permission requests, answers any other request from the agent with a "method not found"
353 /// error, and drops other notifications. A line of invalid JSON, any other message, or the end of
354 /// the agent's output before the response fails the call with `error.AgentProtocolError`. The
355 /// fields hold the client's working state, and the package tests read `initialize`, `modes` and
356 /// `child` directly.
357 pub const Client = struct {
358 /// The allocator for everything the client owns: the argument list, the directory copy, the
359 /// reader's buffer, parsed messages, reply text and the session id.
360 allocator: std.mem.Allocator,
361 /// The argument list the agent started with: the command, then its arguments. The client owns
362 /// the list, and the strings in it are borrowed from `Options`.
363 argv: []const []const u8,
364 /// The client's own copy of `Options.cwd`, or null. The method `start` reads it for
365 /// `session/new`, and `deinit` frees it.
366 cwd: ?[]u8,
367 /// The name, title and version from `Options.client`, sent by `start`.
368 info: ClientInfo,
369 /// The MCP servers from `Options.mcp_servers`, sent by `start`.
370 mcp_servers: []const McpServer,
371 /// The permission lists from `Options.permission_policy`.
372 permission_policy: PermissionPolicy,
373 /// The callbacks from `Options.observer`, or null.
374 observer: ?Observer,
375 /// The traffic caps from `Options.transfer_limits`.
376 transfer_limits: TransferLimits,
377 /// The caller's number for this client from `Options.transport_epoch`, stamped on every update.
378 transport_epoch: u64,
379 /// The number the next update gets. The value starts at 1 and rises by one for each update, and
380 /// nothing resets it.
381 next_update_sequence: u64 = 1,
382 /// The Zig standard library's thread-based I/O (`std.Io.Threaded`) the client created for the
383 /// agent's process and pipes. The state carries the environment the agent started with. The
384 /// method `deinit` frees it.
385 io_state: sys.thread.ThreadedIo,
386 /// The agent process, with the pipes to its standard input and output. The agent runs in a
387 /// process group of its own, and its standard error is discarded.
388 child: sys.process.Child,
389 /// The line reader over the agent's standard output, which holds the reader's buffer.
390 reader: LineReader,
391 /// The id the next request gets. The value starts at 0 and rises by one per request, so
392 /// `initialize` gets 0, `session/new` gets 1, and the first prompt gets 2.
393 next_id: i64 = 0,
394 /// The agent's id for the current session, taken from the `session/new` answer. The field is
395 /// null before `start`, and every prompt call fails with `error.AgentProtocolError` while it is
396 /// null. The client owns it.
397 session_id: ?[]u8 = null,
398 /// The agent's answer to `initialize`, set by `start`. The field holds the empty defaults
399 /// before `start`. Callers read the protocol version, capability flags and authentication
400 /// methods from it.
401 initialize: protocol.Initialize = .{},
402 /// The session's modes from the `session/new` answer, set by `start`. The field is empty when
403 /// the agent reports no modes.
404 modes: protocol.Modes = .{},
405 /// The client's own copy of the message in the latest JSON-RPC error answer from the agent, or
406 /// null. Each later error answer replaces it, and a later success leaves it in place. Callers
407 /// read it through `agentError`.
408 agent_error: ?[]u8 = null,
409
410 /// A caller calls `init` to start the agent and prepare everything the conversation needs, so
411 /// later calls only exchange messages. The function checks the limits, allocates the reader's
412 /// buffer, copies the argument list and the working directory, then starts the agent. The
413 /// function returns `error.InvalidLimits` before it allocates anything when a traffic cap or
414 /// the transport epoch is zero. The call allocates the reader's buffer, the line limit plus one
415 /// byte, before the agent starts, so a limit too large to add one to fails with
416 /// `error.CapacityOverflow` and no process starts. The agent starts in a new process group of
417 /// its own, with pipes on standard input and output and standard error discarded. The call
418 /// returns allocation errors, errors from starting the process, and
419 /// `error.AgentTransportFailed` when the process has no output pipe. On an error after the
420 /// agent starts, the function signals the agent's process group to terminate and then to die,
421 /// and waits for the agent to exit. The call sends the agent no message: the conversation
422 /// begins with `start`. The caller owns the result and ends it with `deinit`.
423 pub fn init(allocator: std.mem.Allocator, options: Options) !Client {
424 if (!options.transfer_limits.valid() or options.transport_epoch == 0) {
425 return error.InvalidLimits;
426 }
427 var reader_storage = try reader.Storage.init(allocator, options.reader_limits);
428 errdefer reader_storage.deinit(allocator);
429 const argv = try allocator.alloc([]const u8, options.args.len + 1);
430 errdefer allocator.free(argv);
431 argv[0] = options.command;
432 @memcpy(argv[1..], options.args);
433 const cwd = if (options.cwd) |value| try allocator.dupe(u8, value) else null;
434 errdefer if (cwd) |value| allocator.free(value);
435 var io_state = sys.thread.initThreadedIo(allocator, .{ .environ = options.environ orelse sys.env.current() });
436 errdefer io_state.deinit();
437 const io = io_state.io();
438 var child = try sys.process.spawn(io, .{
439 .argv = argv,
440 .stdin = .pipe,
441 .stdout = .pipe,
442 .stderr = .ignore,
443 .cwd = if (cwd) |value| .{ .path = value } else .inherit,
444 .pgid = 0,
445 });
446 errdefer stopChildAndReap(&child, io);
447 const stdout = child.stdout orelse return error.AgentTransportFailed;
448 reader_storage.activate();
449 return .{
450 .allocator = allocator,
451 .argv = argv,
452 .cwd = cwd,
453 .info = options.client,
454 .mcp_servers = options.mcp_servers,
455 .permission_policy = options.permission_policy,
456 .observer = options.observer,
457 .transfer_limits = options.transfer_limits,
458 .transport_epoch = options.transport_epoch,
459 .io_state = io_state,
460 .child = child,
461 .reader = LineReader.init(io, stdout, reader_storage),
462 };
463 }
464
465 /// A caller calls `deinit` once to end the agent and free what the client owns when done with
466 /// the client. The function frees the reader's buffer, the stored `initialize` answer, the
467 /// modes, the error message, the session id and the directory copy. The client signals the
468 /// agent's whole process group to terminate and then to die, and waits for the agent to exit.
469 /// The client sends the agent no message first, `session/cancel` included. The call frees the
470 /// I/O instance and the argument list.
471 pub fn deinit(self: *Client) void {
472 self.reader.deinit(self.allocator);
473 self.initialize.deinit(self.allocator);
474 self.modes.deinit(self.allocator);
475 if (self.agent_error) |value| self.allocator.free(value);
476 if (self.session_id) |value| self.allocator.free(value);
477 if (self.cwd) |value| self.allocator.free(value);
478 stopChildAndReap(&self.child, self.io_state.io());
479 self.io_state.deinit();
480 self.allocator.free(self.argv);
481 }
482
483 /// A caller reads the status to learn why reading stopped after
484 /// `error.ReaderMessageCapacityExceeded`, or to watch how close the agent's lines come to the
485 /// limit. The call returns a snapshot of the reader's limit, buffer size, bytes waiting,
486 /// longest line seen, rejected lines, and whether reading has stopped. The package test reads
487 /// it after an overlong line.
488 pub fn readerStatus(self: *const Client) reader.Status {
489 return self.reader.storage.status();
490 }
491
492 /// A caller uses this call to stop the agent and everything it started at once. The client
493 /// signals the agent's whole process group to terminate and then to die, so tools the agent
494 /// started stop too. The call falls back to signalling the agent alone when signalling the
495 /// group fails. The function sends no `session/cancel` message: the client sends that message
496 /// only when a reply reaches a limit. The function returns without waiting for the agent to
497 /// exit, and `deinit` waits for it later. The call returns at once when the process has already
498 /// been reaped. A later call that reads from the agent finds its output closed and fails with
499 /// `error.AgentProtocolError`.
500 pub fn requestCancel(self: *Client) void {
501 stopChild(&self.child, self.io_state.io());
502 }
503
504 /// A caller calls `start` once after `init` and before the first prompt to open the
505 /// conversation. The call sends `initialize` with protocol version 1, an empty set of client
506 /// capabilities and the client's name, title and version, then waits for the answer. The
507 /// function fails with `error.AgentProtocolError` when the agent answers with an error, a
508 /// malformed result, or a protocol version other than 1. The client keeps the message of an
509 /// error answer for `agentError`. The call stores the answer in the `initialize` field. The
510 /// function then sends `session/new` with the working directory and the MCP servers, and stores
511 /// the session id and the modes from the answer. Updates and permission requests that arrive
512 /// meanwhile go through the observer as they do during a prompt. Calling it again runs both
513 /// steps again and replaces the stored answer, session id and modes. The call can also fail
514 /// with `error.RequestCapacityExceeded`, `error.ReaderMessageCapacityExceeded` and the errors
515 /// listed at `promptDetailedContent` for messages that arrive meanwhile.
516 pub fn start(self: *Client) !void {
517 const initialize_id = self.nextRequestId();
518 try self.writeInitialize(initialize_id);
519 var initialize_capture = PromptCapture{};
520 defer initialize_capture.deinit(self.allocator);
521 var initialized = try self.readResponse(initialize_id, &initialize_capture);
522 defer initialized.deinit();
523 _ = try self.responseResult(initialized.value);
524 var initialize = try protocol.Initialize.fromResponse(self.allocator, initialized.value);
525 errdefer initialize.deinit(self.allocator);
526 if (initialize.protocol_version != 1) return error.AgentProtocolError;
527 self.initialize.deinit(self.allocator);
528 self.initialize = initialize;
529
530 const session_id_request = self.nextRequestId();
531 if (self.cwd) |cwd| {
532 try self.writeSessionNew(session_id_request, cwd);
533 } else {
534 const cwd = try sys.fs.cwdAlloc(self.allocator);
535 defer self.allocator.free(cwd);
536 try self.writeSessionNew(session_id_request, cwd);
537 }
538 var session_capture = PromptCapture{};
539 defer session_capture.deinit(self.allocator);
540 var session = try self.readResponse(session_id_request, &session_capture);
541 defer session.deinit();
542 _ = try self.responseResult(session.value);
543 var modes = try protocol.Modes.fromResponse(self.allocator, session.value) orelse protocol.Modes{};
544 errdefer modes.deinit(self.allocator);
545 if (self.session_id) |value| {
546 self.allocator.free(value);
547 self.session_id = null;
548 }
549 self.session_id = try self.extractSessionId(session.value);
550 self.modes.deinit(self.allocator);
551 self.modes = modes;
552 }
553
554 /// A caller uses this call for the common case of sending text and getting the reply text back.
555 /// The call sends the text as one prompt and returns the reply: the text of the agent's message
556 /// chunks, joined in arrival order. The caller owns the returned bytes and frees them with the
557 /// client's allocator. The call drops the stop reason and any overflow record, which
558 /// `promptDetailed` returns. The function fails as `promptDetailedContent` does.
559 pub fn prompt(self: *Client, text: []const u8) ![]u8 {
560 var result = try self.promptDetailed(text);
561 const response = result.response;
562 result.response = &.{};
563 result.deinit(self.allocator);
564 return response;
565 }
566
567 /// A caller uses this call to send text and also learn why the agent stopped and whether the
568 /// reply was cut short. The call sends the text as one text block and returns the reply, the
569 /// stop reason and any overflow record. The caller frees the result with `PromptResult.deinit`
570 /// and the client's allocator. The function fails as `promptDetailedContent` does.
571 pub fn promptDetailed(self: *Client, text: []const u8) !protocol.PromptResult {
572 const content = [_]protocol.PromptContent{.{ .text = text }};
573 return try self.promptDetailedContent(content[0..]);
574 }
575
576 /// A caller uses this call to send a prompt that carries files, links or media alongside text.
577 /// The call sends one `session/prompt` request for the current session with the given blocks in
578 /// order. The function fails with `error.AgentProtocolError` before `start` has set a session.
579 /// The call reads until the prompt's response: each update goes to the observer, and agent
580 /// message chunks join the reply within the reply limits. When one more chunk would pass a
581 /// reply limit, the reply keeps what came before it, the result records the overflow, and the
582 /// client sends one `session/cancel` and keeps waiting for the agent's final response. The call
583 /// returns the reply, the agent's stop reason and the overflow record, and the caller frees the
584 /// result with `PromptResult.deinit`. The function fails with `error.RequestCapacityExceeded`
585 /// when the request is too large to send, and nothing is sent. The call fails with
586 /// `error.UpdateCapacityExceeded` or `error.UpdateCountCapacityExceeded` for an update past a
587 /// limit, `error.ReaderMessageCapacityExceeded` for a line past the line limit,
588 /// `error.MissingDurableObserver` when a callback it needs is missing, and any error an
589 /// observer callback returns. The call fails with `error.AgentProtocolError` for an error
590 /// answer, a malformed message, or a response without a `stopReason`. The call reads the blocks
591 /// and their strings only during the call.
592 pub fn promptDetailedContent(self: *Client, content: []const protocol.PromptContent) !protocol.PromptResult {
593 const session_id = self.session_id orelse return error.AgentProtocolError;
594 const prompt_id = self.nextRequestId();
595 try self.writePrompt(prompt_id, session_id, content);
596 var capture = PromptCapture{};
597 defer capture.deinit(self.allocator);
598 var response = try self.readResponse(prompt_id, &capture);
599 defer response.deinit();
600 const response_text = try capture.chunks.toOwnedSlice(self.allocator);
601 errdefer self.allocator.free(response_text);
602 const stop_reason = try self.extractPromptStop(response.value);
603 errdefer self.allocator.free(stop_reason);
604 return .{
605 .response = response_text,
606 .stop_reason = stop_reason,
607 .overflow = capture.overflow,
608 };
609 }
610
611 fn nextRequestId(self: *Client) i64 {
612 const id = self.next_id;
613 self.next_id += 1;
614 return id;
615 }
616
617 /// A caller uses this call to show what the agent said after a call fails with
618 /// `error.AgentProtocolError`. The call returns the message of the latest JSON-RPC error answer
619 /// from the agent, or null when there has been none. The message stays until a later error
620 /// answer replaces it, so a later success leaves it in place. When copying a new message fails
621 /// for lack of memory, the client keeps the older message. The slice belongs to the client and
622 /// stays valid until the next error answer or `deinit`.
623 pub fn agentError(self: *const Client) ?[]const u8 {
624 return self.agent_error;
625 }
626
627 fn recordAgentError(self: *Client, error_value: std.json.Value) void {
628 const object = json.getObject(error_value) orelse return;
629 const message = json.objectString(object, "message") orelse return;
630 const owned = self.allocator.dupe(u8, message) catch return;
631 if (self.agent_error) |previous| self.allocator.free(previous);
632 self.agent_error = owned;
633 }
634
635 fn responseResult(self: *Client, value: std.json.Value) !std.json.ObjectMap {
636 const object = json.getObject(value) orelse return error.AgentProtocolError;
637 if (object.get("error")) |error_value| {
638 self.recordAgentError(error_value);
639 return error.AgentProtocolError;
640 }
641 const result = object.get("result") orelse return error.AgentProtocolError;
642 return json.getObject(result) orelse return error.AgentProtocolError;
643 }
644
645 fn extractSessionId(self: *Client, value: std.json.Value) ![]u8 {
646 const result = try self.responseResult(value);
647 const session_id = json.objectString(result, "sessionId") orelse return error.AgentProtocolError;
648 return try self.allocator.dupe(u8, session_id);
649 }
650
651 fn extractPromptStop(self: *Client, value: std.json.Value) ![]u8 {
652 const result = try self.responseResult(value);
653 const reason = json.objectString(result, "stopReason") orelse return error.AgentProtocolError;
654 return try self.allocator.dupe(u8, reason);
655 }
656
657 fn writeInitialize(self: *Client, id: i64) !void {
658 var out: std.Io.Writer.Allocating = .init(self.allocator);
659 defer out.deinit();
660 var stream = pretty_json.Writer.init(&out.writer, .minified);
661 const root = try stream.object();
662 try root.field("jsonrpc", "2.0");
663 try root.field("id", id);
664 try root.field("method", "initialize");
665 const params = try root.object("params");
666 try params.field("protocolVersion", 1);
667 try params.field("clientCapabilities", .{});
668 const client_info = try params.object("clientInfo");
669 try client_info.field("name", self.info.name);
670 try client_info.field("title", self.info.title);
671 try client_info.field("version", self.info.version);
672 try client_info.end();
673 try params.end();
674 try root.endLine();
675 try self.send(out.written());
676 }
677
678 fn writeSessionNew(self: *Client, id: i64, cwd: []const u8) !void {
679 var out: std.Io.Writer.Allocating = .init(self.allocator);
680 defer out.deinit();
681 var stream = pretty_json.Writer.init(&out.writer, .minified);
682 const root = try stream.object();
683 try root.field("jsonrpc", "2.0");
684 try root.field("id", id);
685 try root.field("method", "session/new");
686 const params = try root.object("params");
687 try params.field("cwd", cwd);
688 try writeMcpServers(try params.array("mcpServers"), self.mcp_servers);
689 try params.end();
690 try root.endLine();
691 try self.send(out.written());
692 }
693
694 fn writePrompt(
695 self: *Client,
696 id: i64,
697 session_id: []const u8,
698 content: []const protocol.PromptContent,
699 ) !void {
700 var out: std.Io.Writer.Allocating = .init(self.allocator);
701 defer out.deinit();
702 var stream = pretty_json.Writer.init(&out.writer, .minified);
703 const root = try stream.object();
704 try root.field("jsonrpc", "2.0");
705 try root.field("id", id);
706 try root.field("method", "session/prompt");
707 const params = try root.object("params");
708 try params.field("sessionId", session_id);
709 try writePromptContent(try params.array("prompt"), content);
710 try params.end();
711 try root.endLine();
712 try self.send(out.written());
713 }
714
715 fn writePromptContent(array: pretty_json.Array, content: []const protocol.PromptContent) !void {
716 for (content) |item| try writePromptContentItem(array, item);
717 try array.end();
718 }
719
720 fn writePromptContentItem(array: pretty_json.Array, item: protocol.PromptContent) !void {
721 switch (item) {
722 .text => |text| {
723 const object = try array.object();
724 try object.field("type", "text");
725 try object.field("text", text);
726 try object.end();
727 },
728 .image => |media| try writePromptMedia(array, "image", media),
729 .audio => |media| try writePromptMedia(array, "audio", media),
730 .resource_text => |resource| try writePromptTextResource(array, resource),
731 .resource_blob => |resource| try writePromptBlobResource(array, resource),
732 .resource_link => |link| try writePromptResourceLink(array, link),
733 }
734 }
735
736 fn writePromptMedia(
737 array: pretty_json.Array,
738 content_type: []const u8,
739 media: protocol.PromptMedia,
740 ) !void {
741 const object = try array.object();
742 try object.field("type", content_type);
743 try object.field("mimeType", media.mime_type);
744 try object.field("data", media.data);
745 try writeOptionalStringField(object, "uri", media.uri);
746 try object.end();
747 }
748
749 fn writePromptTextResource(
750 array: pretty_json.Array,
751 resource: protocol.PromptTextResource,
752 ) !void {
753 const object = try array.object();
754 try object.field("type", "resource");
755 const value = try object.object("resource");
756 try value.field("uri", resource.uri);
757 try value.field("text", resource.text);
758 try writeOptionalStringField(value, "mimeType", resource.mime_type);
759 try value.end();
760 try object.end();
761 }
762
763 fn writePromptBlobResource(
764 array: pretty_json.Array,
765 resource: protocol.PromptBlobResource,
766 ) !void {
767 const object = try array.object();
768 try object.field("type", "resource");
769 const value = try object.object("resource");
770 try value.field("uri", resource.uri);
771 try value.field("blob", resource.blob);
772 try writeOptionalStringField(value, "mimeType", resource.mime_type);
773 try value.end();
774 try object.end();
775 }
776
777 fn writePromptResourceLink(array: pretty_json.Array, link: protocol.PromptResourceLink) !void {
778 const object = try array.object();
779 try object.field("type", "resource_link");
780 try object.field("uri", link.uri);
781 try object.field("name", link.name);
782 try writeOptionalStringField(object, "mimeType", link.mime_type);
783 try writeOptionalStringField(object, "title", link.title);
784 try writeOptionalStringField(object, "description", link.description);
785 if (link.size) |size| try object.field("size", size);
786 try object.end();
787 }
788
789 fn writeOptionalStringField(
790 object: pretty_json.Object,
791 name: []const u8,
792 value: []const u8,
793 ) !void {
794 if (value.len == 0) return;
795 try object.field(name, value);
796 }
797
798 fn writeErrorResponse(self: *Client, id_value: std.json.Value) !void {
799 var out: std.Io.Writer.Allocating = .init(self.allocator);
800 defer out.deinit();
801 var stream = pretty_json.Writer.init(&out.writer, .minified);
802 const root = try stream.object();
803 try root.field("jsonrpc", "2.0");
804 try root.field("id", id_value);
805 const error_value = try root.object("error");
806 try error_value.field("code", -32601);
807 try error_value.field("message", "method not found");
808 try error_value.end();
809 try root.endLine();
810 try self.send(out.written());
811 }
812
813 fn writePermissionResponse(
814 self: *Client,
815 id_value: std.json.Value,
816 selection: PermissionSelection,
817 ) !void {
818 var out: std.Io.Writer.Allocating = .init(self.allocator);
819 defer out.deinit();
820 var stream = pretty_json.Writer.init(&out.writer, .minified);
821 const root = try stream.object();
822 try root.field("jsonrpc", "2.0");
823 try root.field("id", id_value);
824 const result = try root.object("result");
825 const outcome = try result.object("outcome");
826 try outcome.field("outcome", selection.outcome);
827 try writeOptionalStringField(outcome, "optionId", selection.option_id);
828 try outcome.end();
829 try result.end();
830 try root.endLine();
831 try self.send(out.written());
832 }
833
834 fn writeCancel(self: *Client) !void {
835 const session_id = self.session_id orelse return error.AgentProtocolError;
836 var out: std.Io.Writer.Allocating = .init(self.allocator);
837 defer out.deinit();
838 var stream = pretty_json.Writer.init(&out.writer, .minified);
839 const root = try stream.object();
840 try root.field("jsonrpc", "2.0");
841 try root.field("method", "session/cancel");
842 const params = try root.object("params");
843 try params.field("sessionId", session_id);
844 try params.end();
845 try root.endLine();
846 try self.send(out.written());
847 }
848
849 fn send(self: *Client, bytes: []const u8) !void {
850 if (bytes.len > self.transfer_limits.request_bytes) {
851 return error.RequestCapacityExceeded;
852 }
853 const stdin = self.child.stdin orelse return error.AgentTransportFailed;
854 try fileWriteAll(self.io_state.io(), stdin, bytes);
855 }
856
857 fn selectPermission(self: *Client, object: std.json.ObjectMap) !OwnedSelection {
858 if (self.observer) |observer| {
859 if (observer.permissionFn != null) {
860 var preview = try protocol.PermissionRequest.fromClientRequest(
861 self.allocator,
862 object,
863 "",
864 "pending",
865 "",
866 "",
867 );
868 defer preview.deinit(self.allocator);
869 if (observer.permission(preview)) |reply| {
870 var owned = OwnedSelection{ .allocator = self.allocator, .selection = .{ .outcome = "cancelled" } };
871 errdefer owned.deinit();
872 owned.outcome = try self.allocator.dupe(u8, reply.outcome);
873 owned.option_id = try self.allocator.dupe(u8, reply.option_id);
874 owned.option_kind = try self.allocator.dupe(u8, reply.option_kind);
875 owned.selection = .{
876 .outcome = owned.outcome.?,
877 .option_id = owned.option_id.?,
878 .option_kind = owned.option_kind.?,
879 };
880 return owned;
881 }
882 }
883 }
884 return .{ .allocator = self.allocator, .selection = permissionSelection(object, self.permission_policy) };
885 }
886
887 fn readResponse(self: *Client, id: i64, capture: *PromptCapture) !std.json.Parsed(std.json.Value) {
888 while (try self.reader.nextLine()) |line| {
889 const trimmed = std.mem.trim(u8, line, " \t\r\n");
890 if (trimmed.len == 0) continue;
891 var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, trimmed, .{}) catch return error.AgentProtocolError;
892 if (isResponseFor(parsed.value, id)) return parsed;
893 if (try self.handleIncoming(parsed.value, trimmed, capture)) {
894 parsed.deinit();
895 continue;
896 }
897 parsed.deinit();
898 return error.AgentProtocolError;
899 }
900 return error.AgentProtocolError;
901 }
902
903 fn handleIncoming(
904 self: *Client,
905 value: std.json.Value,
906 envelope: []const u8,
907 capture: *PromptCapture,
908 ) !bool {
909 const object = json.getObject(value) orelse return false;
910 if (json.objectString(object, "method")) |method| {
911 if (std.mem.eql(u8, method, "session/update")) {
912 const update_sequence = self.next_update_sequence;
913 if (update_sequence > self.transfer_limits.update_count) {
914 return error.UpdateCountCapacityExceeded;
915 }
916 if (envelope.len > self.transfer_limits.update_bytes) {
917 return error.UpdateCapacityExceeded;
918 }
919 self.next_update_sequence = std.math.add(
920 u64,
921 update_sequence,
922 1,
923 ) catch return error.UpdateCountCapacityExceeded;
924 var update = (try protocol.Update.fromSessionNotification(
925 self.allocator,
926 object,
927 self.transport_epoch,
928 update_sequence,
929 envelope,
930 )) orelse return error.AgentProtocolError;
931 defer update.deinit(self.allocator);
932 const observer = self.observer orelse
933 return error.MissingDurableObserver;
934 try observer.update(update);
935 if (try capture.appendResponse(
936 self.allocator,
937 update,
938 self.transfer_limits,
939 )) try self.writeCancel();
940 return true;
941 }
942 if (std.mem.eql(u8, method, "session/request_permission")) {
943 const id_value = object.get("id") orelse return false;
944 var selection = try self.selectPermission(object);
945 defer selection.deinit();
946 var request = try protocol.PermissionRequest.fromClientRequest(
947 self.allocator,
948 object,
949 envelope,
950 selection.selection.outcome,
951 selection.selection.option_id,
952 selection.selection.option_kind,
953 );
954 defer request.deinit(self.allocator);
955 const observer = self.observer orelse
956 return error.MissingDurableObserver;
957 try observer.commitPermission(request);
958 try self.writePermissionResponse(id_value, selection.reply());
959 return true;
960 }
961 if (object.get("id")) |id_value| {
962 try self.writeErrorResponse(id_value);
963 return true;
964 }
965 return true;
966 }
967 return false;
968 }
969 };
970
971 const PermissionSelection = struct {
972 outcome: []const u8,
973 option_id: []const u8 = "",
974 option_kind: []const u8 = "",
975 };
976
977 const OwnedSelection = struct {
978 allocator: std.mem.Allocator,
979 selection: PermissionSelection,
980 outcome: ?[]u8 = null,
981 option_id: ?[]u8 = null,
982 option_kind: ?[]u8 = null,
983
984 fn reply(self: OwnedSelection) PermissionSelection {
985 return self.selection;
986 }
987
988 fn deinit(self: *OwnedSelection) void {
989 if (self.outcome) |value| self.allocator.free(value);
990 if (self.option_id) |value| self.allocator.free(value);
991 if (self.option_kind) |value| self.allocator.free(value);
992 self.* = undefined;
993 }
994 };
995
996 const PromptCapture = struct {
997 chunks: std.ArrayList(u8) = .empty,
998 response_segments: usize = 0,
999 overflow: ?protocol.Overflow = null,
1000
1001 fn deinit(self: *PromptCapture, allocator: std.mem.Allocator) void {
1002 self.chunks.deinit(allocator);
1003 self.* = undefined;
1004 }
1005
1006 fn appendResponse(
1007 self: *PromptCapture,
1008 allocator: std.mem.Allocator,
1009 update: protocol.Update,
1010 limits: TransferLimits,
1011 ) !bool {
1012 if (!update.assistantMessage() or self.overflow != null) return false;
1013 const next_segments = std.math.add(
1014 usize,
1015 self.response_segments,
1016 1,
1017 ) catch return self.setOverflow(.response_segments, update);
1018 if (next_segments > limits.response_segment_count) {
1019 return self.setOverflow(.response_segments, update);
1020 }
1021 const next_bytes = std.math.add(
1022 usize,
1023 self.chunks.items.len,
1024 update.text.len,
1025 ) catch return self.setOverflow(.response_bytes, update);
1026 if (next_bytes > limits.response_bytes) {
1027 return self.setOverflow(.response_bytes, update);
1028 }
1029 try self.chunks.appendSlice(allocator, update.text);
1030 self.response_segments = next_segments;
1031 return false;
1032 }
1033
1034 fn setOverflow(
1035 self: *PromptCapture,
1036 kind: protocol.OverflowKind,
1037 update: protocol.Update,
1038 ) bool {
1039 self.overflow = .{
1040 .kind = kind,
1041 .admitted_bytes = self.chunks.items.len,
1042 .admitted_segments = self.response_segments,
1043 .withheld_update_sequence = update.update_sequence,
1044 };
1045 return true;
1046 }
1047 };
1048
1049 fn permissionSelection(object: std.json.ObjectMap, policy: PermissionPolicy) PermissionSelection {
1050 const params = json.objectObject(object, "params") orelse return .{ .outcome = "cancelled" };
1051 const options_value = params.get("options") orelse return .{ .outcome = "cancelled" };
1052 const options = switch (options_value) {
1053 .array => |array| array,
1054 else => return .{ .outcome = "cancelled" },
1055 };
1056 if (policy.allows(object)) {
1057 if (permissionOption(options, "allow_once")) |selection| return selection;
1058 if (permissionOption(options, "allow_always")) |selection| return selection;
1059 }
1060 if (permissionOption(options, "reject_once")) |selection| return selection;
1061 var fallback: PermissionSelection = .{ .outcome = "cancelled" };
1062 for (options.items) |item| {
1063 const option = json.getObject(item) orelse continue;
1064 const kind = json.objectString(option, "kind") orelse continue;
1065 const option_id = json.objectString(option, "optionId") orelse continue;
1066 if (std.mem.eql(u8, kind, "reject_always")) fallback = .{ .outcome = "selected", .option_id = option_id, .option_kind = kind };
1067 }
1068 return fallback;
1069 }
1070
1071 fn permissionOption(options: std.json.Array, target_kind: []const u8) ?PermissionSelection {
1072 for (options.items) |item| {
1073 const option = json.getObject(item) orelse continue;
1074 const kind = json.objectString(option, "kind") orelse continue;
1075 if (!std.mem.eql(u8, kind, target_kind)) continue;
1076 const option_id = json.objectString(option, "optionId") orelse continue;
1077 return .{ .outcome = "selected", .option_id = option_id, .option_kind = kind };
1078 }
1079 return null;
1080 }
1081
1082 fn stopChild(child: *sys.process.Child, io: anytype) void {
1083 const child_id = child.id orelse return;
1084 sys.process.signalChildGroup(child_id, .terminate) catch sys.process.requestTermination(child, io);
1085 sys.process.signalChildGroup(child_id, .kill) catch sys.process.forceKillChildId(child_id);
1086 }
1087
1088 fn stopChildAndReap(child: *sys.process.Child, io: anytype) void {
1089 stopChild(child, io);
1090 sys.process.killAndReap(child, io);
1091 }
1092
1093 fn fileWriteAll(io: std.Io, file: std.Io.File, bytes: []const u8) !void {
1094 var buf: [4096]u8 = undefined;
1095 var writer = file.writer(io, &buf);
1096 try writer.interface.writeAll(bytes);
1097 try writer.interface.flush();
1098 }
1099
1100 fn writeMcpServers(array: pretty_json.Array, servers: []const McpServer) !void {
1101 for (servers) |server| {
1102 const object = try array.object();
1103 try object.field("name", server.name);
1104 try object.field("command", server.command);
1105 const args = try object.array("args");
1106 for (server.args) |arg| try args.element(arg);
1107 try args.end();
1108 const env_values = try object.array("env");
1109 for (server.env) |env| {
1110 const value = try env_values.object();
1111 try value.field("name", env.name);
1112 try value.field("value", env.value);
1113 try value.end();
1114 }
1115 try env_values.end();
1116 try object.end();
1117 }
1118 try array.end();
1119 }
1120
1121 fn isResponseFor(value: std.json.Value, id: i64) bool {
1122 const object = json.getObject(value) orelse return false;
1123 const actual = json.objectInteger(object, "id") orelse return false;
1124 return actual == id and (object.get("result") != null or object.get("error") != null);
1125 }
1126
1127 const LineReader = struct {
1128 io: std.Io,
1129 file: std.Io.File,
1130 storage: reader.Storage,
1131
1132 fn init(io: std.Io, file: std.Io.File, storage: reader.Storage) LineReader {
1133 return .{
1134 .io = io,
1135 .file = file,
1136 .storage = storage,
1137 };
1138 }
1139
1140 fn deinit(self: *LineReader, allocator: std.mem.Allocator) void {
1141 self.storage.deinit(allocator);
1142 }
1143
1144 fn nextLine(self: *LineReader) !?[]const u8 {
1145 while (true) {
1146 switch (try self.storage.poll()) {
1147 .line => |line| return stripLineEnding(line),
1148 .end => return null,
1149 .need_input => {},
1150 }
1151 var dest = [_][]u8{self.storage.writable()};
1152 const bytes_read = self.file.readStreaming(self.io, &dest) catch |err| switch (err) {
1153 error.EndOfStream => 0,
1154 else => return err,
1155 };
1156 if (bytes_read == 0) {
1157 self.storage.finish();
1158 } else {
1159 self.storage.commit(bytes_read);
1160 }
1161 }
1162 }
1163 };
1164
1165 fn stripLineEnding(line: []const u8) []const u8 {
1166 if (line.len > 0 and line[line.len - 1] == '\r') return line[0 .. line.len - 1];
1167 return line;
1168 }
1169
1170 test "client writes structured ACP prompt content" {
1171 var out: std.Io.Writer.Allocating = .init(std.testing.allocator);
1172 defer out.deinit();
1173 const content = [_]protocol.PromptContent{
1174 .{ .text = "hello" },
1175 .{ .resource_text = .{
1176 .uri = "file:///tmp/context.zig",
1177 .text = "const answer = 42;",
1178 .mime_type = "text/zig",
1179 } },
1180 .{ .resource_link = .{
1181 .uri = "file:///tmp/notes.md",
1182 .name = "notes.md",
1183 .mime_type = "text/markdown",
1184 .title = "Notes",
1185 .description = "user notes",
1186 .size = 12,
1187 } },
1188 };
1189 var stream = pretty_json.Writer.init(&out.writer, .minified);
1190 try Client.writePromptContent(try stream.array(), content[0..]);
1191 try std.testing.expectEqualStrings(
1192 "[{\"type\":\"text\",\"text\":\"hello\"},{\"type\":\"resource\",\"resource\":{\"uri\":\"file:///tmp/context.zig\",\"text\":\"const answer = 42;\",\"mimeType\":\"text/zig\"}},{\"type\":\"resource_link\",\"uri\":\"file:///tmp/notes.md\",\"name\":\"notes.md\",\"mimeType\":\"text/markdown\",\"title\":\"Notes\",\"description\":\"user notes\",\"size\":12}]",
1193 out.written(),
1194 );
1195 }
1196
1197 test "client collects ACP agent message chunks" {
1198 const script =
1199 \\prompt_count=0
1200 \\while IFS= read -r line; do
1201 \\ case "$line" in
1202 \\ *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
1203 \\ *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test","modes":{"currentModeId":"plan","availableModes":[{"id":"plan","name":"Plan"},{"id":"act","name":"Act"}]}}}' ;;
1204 \\ *'"method":"session/prompt"'*)
1205 \\ prompt_count=$((prompt_count + 1))
1206 \\ if [ "$prompt_count" -eq 1 ]; then
1207 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"{\"decision\":\"accept\"}"}}}}'
1208 \\ printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'
1209 \\ fi
1210 \\ ;;
1211 \\ esac
1212 \\done
1213 ;
1214 var journal = TestJournal{};
1215 var client = try Client.init(std.testing.allocator, .{
1216 .command = "/bin/sh",
1217 .reader_limits = reader.default_limits,
1218 .transfer_limits = test_transfer_limits,
1219 .transport_epoch = 1,
1220 .args = &.{ "-c", script },
1221 .client = .{ .name = "acp-test", .title = "acp-test" },
1222 .observer = journal.observer(),
1223 });
1224 defer client.deinit();
1225 try client.start();
1226 try std.testing.expectEqual(@as(i64, 1), client.initialize.protocol_version);
1227 const response = try client.prompt("hello");
1228 defer std.testing.allocator.free(response);
1229 try std.testing.expectEqualStrings("{\"decision\":\"accept\"}", response);
1230 }
1231
1232 test "client preserves ACP initialize capabilities" {
1233 const script =
1234 \\while IFS= read -r line; do
1235 \\ case "$line" in
1236 \\ *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"embeddedContent":true}},"authMethods":[{"id":"token"}]}}' ;;
1237 \\ *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test","modes":{"currentModeId":"plan","availableModes":[{"id":"plan","name":"Plan"},{"id":"act","name":"Act"}]}}}' ;;
1238 \\ esac
1239 \\done
1240 ;
1241 var client = try Client.init(std.testing.allocator, .{
1242 .command = "/bin/sh",
1243 .reader_limits = reader.default_limits,
1244 .transfer_limits = test_transfer_limits,
1245 .transport_epoch = 1,
1246 .args = &.{ "-c", script },
1247 .client = .{ .name = "acp-test", .title = "acp-test" },
1248 });
1249 defer client.deinit();
1250 try client.start();
1251 try std.testing.expectEqual(@as(i64, 1), client.initialize.protocol_version);
1252 try std.testing.expect(client.initialize.load_session);
1253 try std.testing.expect(client.initialize.prompt_image);
1254 try std.testing.expect(client.initialize.prompt_embedded_content);
1255 try std.testing.expectEqual(@as(usize, 1), client.initialize.auth_methods);
1256 try std.testing.expectEqualStrings("[\"token\"]", client.initialize.auth_method_ids);
1257 try std.testing.expectEqualStrings("plan", client.modes.current);
1258 try std.testing.expectEqual(@as(usize, 2), client.modes.available);
1259 }
1260
1261 test "client preserves ACP session updates" {
1262 const script =
1263 \\while IFS= read -r line; do
1264 \\ case "$line" in
1265 \\ *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
1266 \\ *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;
1267 \\ *'"method":"session/prompt"'*)
1268 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"thinking"}}}}'
1269 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"plan","entries":[{"content":"inspect","status":"pending"}]}}}'
1270 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"done"}}}}'
1271 \\ printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'
1272 \\ ;;
1273 \\ esac
1274 \\done
1275 ;
1276 var journal = TestJournal{};
1277 var client = try Client.init(std.testing.allocator, .{
1278 .command = "/bin/sh",
1279 .reader_limits = reader.default_limits,
1280 .transfer_limits = test_transfer_limits,
1281 .transport_epoch = 1,
1282 .args = &.{ "-c", script },
1283 .client = .{ .name = "acp-test", .title = "acp-test" },
1284 .observer = journal.observer(),
1285 });
1286 defer client.deinit();
1287 try client.start();
1288 var result = try client.promptDetailed("hello");
1289 defer result.deinit(std.testing.allocator);
1290 try std.testing.expectEqualStrings("done", result.response);
1291 try std.testing.expectEqualStrings("end_turn", result.stop_reason);
1292 try std.testing.expectEqual(@as(usize, 3), journal.update_count);
1293 try std.testing.expectEqual(protocol.UpdateKind.agent_thought_chunk, journal.update_kinds[0]);
1294 try std.testing.expectEqualStrings("thinking", journal.update_text[0][0..journal.update_text_lengths[0]]);
1295 try std.testing.expectEqual(protocol.UpdateKind.plan, journal.update_kinds[1]);
1296 try std.testing.expectEqualStrings("inspect", journal.update_text[1][0..journal.update_text_lengths[1]]);
1297 try std.testing.expectEqualSlices(u64, &.{ 1, 2, 3 }, journal.update_sequences[0..3]);
1298 }
1299
1300 const FixtureJournal = struct {
1301 expected: []const []const u8,
1302 index: usize = 0,
1303 transport_epoch: u64,
1304
1305 fn onUpdate(context: *anyopaque, update: protocol.Update) !void {
1306 const self: *FixtureJournal = @ptrCast(@alignCast(context));
1307 if (self.index >= self.expected.len) return error.UnexpectedUpdate;
1308 try std.testing.expectEqual(self.transport_epoch, update.transport_epoch);
1309 try std.testing.expectEqual(self.index + 1, update.update_sequence);
1310 try std.testing.expectEqualStrings(self.expected[self.index], update.envelope);
1311 self.index += 1;
1312 }
1313 };
1314
1315 test "client frames every official ACP v1 session update variant exactly once" {
1316 const fixtures = [_][]const u8{
1317 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"user_message_chunk\",\"content\":{\"type\":\"text\",\"text\":\"user\"}}}}",
1318 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"agent_message_chunk\",\"content\":{\"type\":\"text\",\"text\":\"agent\"}}}}",
1319 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"agent_thought_chunk\",\"content\":{\"type\":\"text\",\"text\":\"thought\"}}}}",
1320 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"call-1\",\"title\":\"Read\",\"kind\":\"read\",\"status\":\"pending\",\"content\":[]}}}",
1321 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"tool_call_update\",\"toolCallId\":\"call-1\",\"status\":\"completed\",\"content\":[]}}}",
1322 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"plan\",\"entries\":[]}}}",
1323 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"available_commands_update\",\"availableCommands\":[]}}}",
1324 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"current_mode_update\",\"currentModeId\":\"code\"}}}",
1325 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"config_option_update\",\"configOptions\":[]}}}",
1326 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"session_info_update\",\"title\":\"Session\",\"updatedAt\":\"2026-08-15T00:00:00Z\"}}}",
1327 "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"usage_update\",\"used\":1,\"size\":2}}}",
1328 };
1329 const script =
1330 \\while IFS= read -r line; do
1331 \\ case "$line" in
1332 \\ *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
1333 \\ *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;
1334 \\ *'"method":"session/prompt"'*)
1335 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"user"}}}}'
1336 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"agent"}}}}'
1337 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"thought"}}}}'
1338 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"Read","kind":"read","status":"pending","content":[]}}}'
1339 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[]}}}'
1340 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"plan","entries":[]}}}'
1341 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}'
1342 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"current_mode_update","currentModeId":"code"}}}'
1343 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"config_option_update","configOptions":[]}}}'
1344 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"session_info_update","title":"Session","updatedAt":"2026-08-15T00:00:00Z"}}}'
1345 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"usage_update","used":1,"size":2}}}'
1346 \\ printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'
1347 \\ ;;
1348 \\ esac
1349 \\done
1350 ;
1351 var journal = FixtureJournal{
1352 .expected = &fixtures,
1353 .transport_epoch = 9,
1354 };
1355 var client = try Client.init(std.testing.allocator, .{
1356 .command = "/bin/sh",
1357 .reader_limits = reader.default_limits,
1358 .transfer_limits = test_transfer_limits,
1359 .transport_epoch = 9,
1360 .args = &.{ "-c", script },
1361 .observer = .{ .ptr = &journal, .updateFn = FixtureJournal.onUpdate },
1362 });
1363 defer client.deinit();
1364 try client.start();
1365 var result = try client.promptDetailed("fixture");
1366 defer result.deinit(std.testing.allocator);
1367 try std.testing.expectEqual(fixtures.len, journal.index);
1368 try std.testing.expectEqualStrings("agent", result.response);
1369 }
1370
1371 test "client rejects and records ACP permission callbacks" {
1372 const script =
1373 \\while IFS= read -r line; do
1374 \\ case "$line" in
1375 \\ *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
1376 \\ *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;
1377 \\ *'"method":"session/prompt"'*)
1378 \\ printf '%s\n' '{"jsonrpc":"2.0","id":"perm-1","method":"session/request_permission","params":{"sessionId":"acp-test","toolCall":{"toolCallId":"tc1","title":"run command","kind":"execute","status":"pending"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"},{"optionId":"reject","name":"Reject","kind":"reject_once"}]}}'
1379 \\ ;;
1380 \\ *'"id":"perm-1"'*'"outcome":"selected"'*'"optionId":"reject"'*)
1381 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"denied"}}}}'
1382 \\ printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'
1383 \\ ;;
1384 \\ esac
1385 \\done
1386 ;
1387 var journal = TestJournal{};
1388 var client = try Client.init(std.testing.allocator, .{
1389 .command = "/bin/sh",
1390 .reader_limits = reader.default_limits,
1391 .transfer_limits = test_transfer_limits,
1392 .transport_epoch = 1,
1393 .args = &.{ "-c", script },
1394 .client = .{ .name = "acp-test", .title = "acp-test" },
1395 .observer = journal.observer(),
1396 });
1397 defer client.deinit();
1398 try client.start();
1399 var result = try client.promptDetailed("hello");
1400 defer result.deinit(std.testing.allocator);
1401 try std.testing.expectEqualStrings("denied", result.response);
1402 try std.testing.expectEqual(@as(usize, 1), journal.permission_count);
1403 try std.testing.expectEqualStrings("acp-test", journal.permission.session_id[0..journal.permission.session_id_len]);
1404 try std.testing.expectEqualStrings("tc1", journal.permission.tool_call_id[0..journal.permission.tool_call_id_len]);
1405 try std.testing.expectEqualStrings("run command", journal.permission.title[0..journal.permission.title_len]);
1406 try std.testing.expectEqualStrings("execute", journal.permission.tool_kind[0..journal.permission.tool_kind_len]);
1407 try std.testing.expectEqualStrings("pending", journal.permission.status[0..journal.permission.status_len]);
1408 try std.testing.expectEqualStrings("selected", journal.permission.outcome[0..journal.permission.outcome_len]);
1409 try std.testing.expectEqualStrings("reject", journal.permission.option_id[0..journal.permission.option_id_len]);
1410 try std.testing.expectEqualStrings("reject_once", journal.permission.option_kind[0..journal.permission.option_kind_len]);
1411 try std.testing.expectEqual(@as(usize, 2), journal.permission.options);
1412 }
1413
1414 test "client allows granted ACP permission callbacks" {
1415 const script =
1416 \\while IFS= read -r line; do
1417 \\ case "$line" in
1418 \\ *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
1419 \\ *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;
1420 \\ *'"method":"session/prompt"'*)
1421 \\ printf '%s\n' '{"jsonrpc":"2.0","id":"perm-1","method":"session/request_permission","params":{"sessionId":"acp-test","toolCall":{"toolCallId":"tc1","title":"run command","kind":"execute","status":"pending"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"},{"optionId":"reject","name":"Reject","kind":"reject_once"}]}}'
1422 \\ ;;
1423 \\ *'"id":"perm-1"'*'"outcome":"selected"'*'"optionId":"allow"'*)
1424 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"allowed"}}}}'
1425 \\ printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'
1426 \\ ;;
1427 \\ esac
1428 \\done
1429 ;
1430 var journal = TestJournal{};
1431 var client = try Client.init(std.testing.allocator, .{
1432 .command = "/bin/sh",
1433 .reader_limits = reader.default_limits,
1434 .transfer_limits = test_transfer_limits,
1435 .transport_epoch = 1,
1436 .args = &.{ "-c", script },
1437 .client = .{ .name = "acp-test", .title = "acp-test" },
1438 .permission_policy = .{ .allowed_tool_kinds = &.{"execute"} },
1439 .observer = journal.observer(),
1440 });
1441 defer client.deinit();
1442 try client.start();
1443 var result = try client.promptDetailed("hello");
1444 defer result.deinit(std.testing.allocator);
1445 try std.testing.expectEqualStrings("allowed", result.response);
1446 try std.testing.expectEqual(@as(usize, 1), journal.permission_count);
1447 try std.testing.expectEqualStrings("selected", journal.permission.outcome[0..journal.permission.outcome_len]);
1448 try std.testing.expectEqualStrings("allow", journal.permission.option_id[0..journal.permission.option_id_len]);
1449 try std.testing.expectEqualStrings("allow_once", journal.permission.option_kind[0..journal.permission.option_kind_len]);
1450 }
1451
1452 test "client allows granted MCP tool titles without a kind" {
1453 const script =
1454 \\while IFS= read -r line; do
1455 \\ case "$line" in
1456 \\ *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
1457 \\ *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;
1458 \\ *'"method":"session/prompt"'*)
1459 \\ printf '%s\n' '{"jsonrpc":"2.0","id":"perm-1","method":"session/request_permission","params":{"sessionId":"acp-test","toolCall":{"toolCallId":"tc1","title":"mcp__fixture__tool","status":"pending"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"},{"optionId":"reject","name":"Reject","kind":"reject_once"}]}}'
1460 \\ ;;
1461 \\ *'"id":"perm-1"'*'"outcome":"selected"'*'"optionId":"allow"'*)
1462 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"granted"}}}}'
1463 \\ printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'
1464 \\ ;;
1465 \\ esac
1466 \\done
1467 ;
1468 var journal = TestJournal{};
1469 var client = try Client.init(std.testing.allocator, .{
1470 .command = "/bin/sh",
1471 .reader_limits = reader.default_limits,
1472 .transfer_limits = test_transfer_limits,
1473 .transport_epoch = 1,
1474 .args = &.{ "-c", script },
1475 .client = .{ .name = "acp-test", .title = "acp-test" },
1476 .permission_policy = .{ .allowed_title_prefixes = &.{"mcp__fixture__"} },
1477 .observer = journal.observer(),
1478 });
1479 defer client.deinit();
1480 try client.start();
1481 var result = try client.promptDetailed("hello");
1482 defer result.deinit(std.testing.allocator);
1483 try std.testing.expectEqualStrings("granted", result.response);
1484 try std.testing.expectEqual(@as(usize, 1), journal.permission_count);
1485 try std.testing.expectEqualStrings("selected", journal.permission.outcome[0..journal.permission.outcome_len]);
1486 try std.testing.expectEqualStrings("allow", journal.permission.option_id[0..journal.permission.option_id_len]);
1487 try std.testing.expectEqualStrings("allow_once", journal.permission.option_kind[0..journal.permission.option_kind_len]);
1488 }
1489
1490 const UpdateCounter = struct {
1491 count: usize = 0,
1492
1493 fn onUpdate(context: *anyopaque, _: protocol.Update) !void {
1494 const self: *UpdateCounter = @ptrCast(@alignCast(context));
1495 self.count +|= 1;
1496 }
1497 };
1498
1499 test "client admits reader capacity before spawning the agent" {
1500 comptime {
1501 @stardustClaim(
1502 @import("alloc_phase").capacity.witness(@import("./reader/root.zig").Storage, "acp_reader_admission"),
1503 null,
1504 null,
1505 null,
1506 null,
1507 null,
1508 null,
1509 );
1510 }
1511
1512 try std.testing.expectError(
1513 error.CapacityOverflow,
1514 Client.init(std.testing.allocator, .{
1515 .command = "/does/not/exist",
1516 .reader_limits = .{ .message_bytes = std.math.maxInt(usize) },
1517 .transfer_limits = test_transfer_limits,
1518 .transport_epoch = 1,
1519 }),
1520 );
1521 }
1522
1523 test "client rejects oversized ACP output before parsing" {
1524 comptime {
1525 @stardustClaim(
1526 @import("alloc_phase").capacity.witness(@import("./reader/root.zig").Storage, "acp_reader_client_terminal_overload"),
1527 null,
1528 null,
1529 null,
1530 null,
1531 null,
1532 null,
1533 );
1534 }
1535 comptime {
1536 @stardustClaim(
1537 @import("alloc_phase").capacity.witness(@import("./reader/root.zig").Storage, "acp_reader_client_terminal_foreign_risk"),
1538 null,
1539 null,
1540 null,
1541 null,
1542 null,
1543 null,
1544 );
1545 }
1546
1547 const script =
1548 \\while IFS= read -r line; do
1549 \\ printf '%s' '{"jsonrpc":"2.0","method":"session/update",'
1550 \\ printf '%s' '"params":{"sessionId":"acp-test","update":'
1551 \\ printf '%s' '{"sessionUpdate":"agent_message_chunk",'
1552 \\ printf '%s\n' '"content":{"type":"text","text":"oversized"}}}}'
1553 \\done
1554 ;
1555 var updates = UpdateCounter{};
1556 var client = try Client.init(std.testing.allocator, .{
1557 .command = "/bin/sh",
1558 .reader_limits = .{ .message_bytes = 32 },
1559 .transfer_limits = test_transfer_limits,
1560 .transport_epoch = 1,
1561 .args = &.{ "-c", script },
1562 .observer = .{ .ptr = &updates, .updateFn = UpdateCounter.onUpdate },
1563 });
1564 defer client.deinit();
1565
1566 try std.testing.expectError(error.ReaderMessageCapacityExceeded, client.start());
1567 try std.testing.expectEqual(@as(usize, 0), updates.count);
1568 try std.testing.expectEqual(reader.Status{
1569 .phase = .steady,
1570 .message_bytes = 32,
1571 .storage_bytes = 33,
1572 .buffered_bytes = 33,
1573 .high_water_message_bytes = 32,
1574 .rejected_message_count = 1,
1575 .terminal = true,
1576 }, client.readerStatus());
1577 }
1578
1579 test "client refuses an oversized request before forwarding" {
1580 const script =
1581 \\while IFS= read -r line; do
1582 \\ case "$line" in
1583 \\ *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
1584 \\ *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;
1585 \\ *'"method":"session/prompt"'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"forwarded"}}' ;;
1586 \\ esac
1587 \\done
1588 ;
1589 var limits = test_transfer_limits;
1590 limits.request_bytes = 512;
1591 var client = try Client.init(std.testing.allocator, .{
1592 .command = "/bin/sh",
1593 .reader_limits = reader.default_limits,
1594 .transfer_limits = limits,
1595 .transport_epoch = 1,
1596 .args = &.{ "-c", script },
1597 });
1598 defer client.deinit();
1599 try client.start();
1600 var request: [1024]u8 = @splat('x');
1601 try std.testing.expectError(
1602 error.RequestCapacityExceeded,
1603 client.prompt(&request),
1604 );
1605 }
1606
1607 test "client commits admitted response segments and settles overflow" {
1608 const script =
1609 \\while IFS= read -r line; do
1610 \\ case "$line" in
1611 \\ *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
1612 \\ *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;
1613 \\ *'"method":"session/prompt"'*)
1614 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}}}}'
1615 \\ printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"no"}}}}'
1616 \\ ;;
1617 \\ *'"method":"session/cancel"'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"cancelled"}}' ;;
1618 \\ esac
1619 \\done
1620 ;
1621 var limits = test_transfer_limits;
1622 limits.response_bytes = 3;
1623 var journal = TestJournal{};
1624 var client = try Client.init(std.testing.allocator, .{
1625 .command = "/bin/sh",
1626 .reader_limits = reader.default_limits,
1627 .transfer_limits = limits,
1628 .transport_epoch = 3,
1629 .args = &.{ "-c", script },
1630 .observer = journal.observer(),
1631 });
1632 defer client.deinit();
1633 try client.start();
1634 var result = try client.promptDetailed("overflow");
1635 defer result.deinit(std.testing.allocator);
1636 try std.testing.expectEqualStrings("ok", result.response);
1637 try std.testing.expectEqualStrings("cancelled", result.stop_reason);
1638 try std.testing.expectEqual(@as(usize, 2), journal.update_count);
1639 const overflow = result.overflow orelse return error.ExpectedOverflow;
1640 try std.testing.expectEqual(protocol.OverflowKind.response_bytes, overflow.kind);
1641 try std.testing.expectEqual(@as(usize, 2), overflow.admitted_bytes);
1642 try std.testing.expectEqual(@as(usize, 1), overflow.admitted_segments);
1643 try std.testing.expectEqual(@as(u64, 2), overflow.withheld_update_sequence);
1644 }
1645
1646 test "client cancellation kills process group" {
1647 if (sys.process.childSignalPolicy() == .unsupported) return error.SkipZigTest;
1648 var client = try Client.init(std.testing.allocator, .{
1649 .command = "/bin/sh",
1650 .reader_limits = reader.default_limits,
1651 .transfer_limits = test_transfer_limits,
1652 .transport_epoch = 1,
1653 .args = &.{ "-c", "trap '' TERM; while true; do sleep 5 & wait; done" },
1654 });
1655 defer client.deinit();
1656 const child_id = client.child.id orelse return error.ExpectedChild;
1657
1658 client.requestCancel();
1659 const deadline = sys.time.nanoTimestamp() + 2 * std.time.ns_per_s;
1660 while (sys.time.nanoTimestamp() < deadline) {
1661 if (try sys.process.waitNoHang(child_id)) |_| {
1662 client.child.id = null;
1663 return;
1664 }
1665 sys.thread.yield();
1666 }
1667 return error.ExpectedChildExit;
1668 }
1669
1670 test "client serializes stdio MCP servers for session setup" {
1671 var out: std.Io.Writer.Allocating = .init(std.testing.allocator);
1672 defer out.deinit();
1673 var stream = pretty_json.Writer.init(&out.writer, .minified);
1674 try writeMcpServers(try stream.array(), &.{
1675 .{
1676 .name = "sample",
1677 .command = "/bin/sample-mcp",
1678 .args = &.{ "serve", "stdio" },
1679 .env = &.{.{ .name = "SAMPLE_SESSION", .value = "session.jsonl" }},
1680 },
1681 });
1682 const expected =
1683 "[{\"name\":\"sample\",\"command\":\"/bin/sample-mcp\"," ++
1684 "\"args\":[\"serve\",\"stdio\"],\"env\":[" ++
1685 "{\"name\":\"SAMPLE_SESSION\",\"value\":\"session.jsonl\"}]}]";
1686 try std.testing.expectEqualStrings(expected, out.written());
1687 }