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 }