Skip to documentation
SLOP

tiny.acp.client

Reference tiny.acp client

Defined in tiny.acp.

The client side of one conversation with a coding agent: the options that start the agent, the callbacks and lists that let the caller watch and steer it, and the calls that run the conversation.

API (9)

Types and contracts

Public types and contracts.

No direct callersNo direct callstiny.acpclient
Static calls · unresolved targets: unknown · external targets: unknown.

Source

Source: lib/acp/src/client.zig

zig
//! The client side of one conversation with a coding agent: the options that start the agent, the//! callbacks and lists that let the caller watch and steer it, and the calls that run the//! conversation. A caller fills in the options, starts the agent, opens a session, sends a prompt//! for each request, and ends the agent when done.//!//! Each call has to return the agent's answer to that call, while every message the agent sends//! along the way reaches the caller's record in order and every permission request gets an answer.//!//! The agent mixes progress messages and requests of its own into the stream before the response a//! call waits for. The agent can stream more reply text than the caller wants to hold.//!//! The calls are `Client.init`, `start`, the prompt calls and `deinit`, and they run one at a time://! each call reads until its own response arrives. While it waits, the call answers permission//! requests, answers any other request from the agent with a "method not found" error, and drops//! other notifications, and anything else fails the call with `error.AgentProtocolError`. Each//! update goes to a set of callbacks the caller supplies (the _observer_) before its text joins the//! reply. The callbacks receive the exact line the agent sent (the _envelope_), trimmed of//! surrounding whitespace, and it stays valid only while the call runs. The client numbers the//! updates from 1 in arrival order (the _update sequence_) for its whole life, and stamps each with//! the caller's number for this client. When one more chunk would push the reply past a limit, the//! reply keeps what came before it, the result records which update was left out, and the client//! sends one `session/cancel` and keeps waiting for the agent's final response. For a permission//! request the observer may answer first, and the decision reaches the observer before the answer//! reaches the agent. When the observer gives no answer, the client checks two caller lists, tool//! kinds and tool-title prefixes (the _permission policy_), and rejects what neither list allows.//! The client starts the agent in a process group of its own, and cancel and teardown signal the//! whole group to terminate and then to die.//!//! - *transport epoch*: a nonzero number the caller picks for one client.//! - *overflow*: a record that a reply stopped at a limit.const std = @import("std");const sys = @import("sys");const pretty = @import("pretty");const json = @import("json.zig");const protocol = @import("protocol.zig");const reader = @import("reader/root.zig");const pretty_json = pretty.json;const test_transfer_limits: TransferLimits = .{    .request_bytes = 1024 * 1024,    .response_bytes = 1024 * 1024,    .update_bytes = 1024 * 1024,    .update_count = 1024,    .response_segment_count = 1024,};const TestJournal = struct {    update_count: usize = 0,    permission_count: usize = 0,    update_kinds: [16]protocol.UpdateKind = undefined,    update_sequences: [16]u64 = undefined,    update_text: [16][128]u8 = undefined,    update_text_lengths: [16]usize = undefined,    permission: PermissionSnapshot = .{},    const PermissionSnapshot = struct {        session_id: [64]u8 = undefined,        session_id_len: usize = 0,        tool_call_id: [64]u8 = undefined,        tool_call_id_len: usize = 0,        title: [128]u8 = undefined,        title_len: usize = 0,        tool_kind: [64]u8 = undefined,        tool_kind_len: usize = 0,        status: [64]u8 = undefined,        status_len: usize = 0,        outcome: [64]u8 = undefined,        outcome_len: usize = 0,        option_id: [64]u8 = undefined,        option_id_len: usize = 0,        option_kind: [64]u8 = undefined,        option_kind_len: usize = 0,        options: usize = 0,    };    fn observer(self: *TestJournal) Observer {        return .{            .ptr = self,            .updateFn = onUpdate,            .permissionCommitFn = onPermission,        };    }    fn onUpdate(context: *anyopaque, update: protocol.Update) !void {        const self: *TestJournal = @ptrCast(@alignCast(context));        if (self.update_count == self.update_kinds.len or            update.text.len > self.update_text[0].len)        {            return error.TestJournalCapacity;        }        const index = self.update_count;        self.update_kinds[index] = update.kind;        self.update_sequences[index] = update.update_sequence;        @memcpy(self.update_text[index][0..update.text.len], update.text);        self.update_text_lengths[index] = update.text.len;        self.update_count += 1;    }    fn onPermission(context: *anyopaque, request: protocol.PermissionRequest) !void {        const self: *TestJournal = @ptrCast(@alignCast(context));        self.permission_count += 1;        try copyTestField(&self.permission.session_id, &self.permission.session_id_len, request.session_id);        try copyTestField(&self.permission.tool_call_id, &self.permission.tool_call_id_len, request.tool_call_id);        try copyTestField(&self.permission.title, &self.permission.title_len, request.title);        try copyTestField(&self.permission.tool_kind, &self.permission.tool_kind_len, request.tool_kind);        try copyTestField(&self.permission.status, &self.permission.status_len, request.status);        try copyTestField(&self.permission.outcome, &self.permission.outcome_len, request.outcome);        try copyTestField(&self.permission.option_id, &self.permission.option_id_len, request.option_id);        try copyTestField(&self.permission.option_kind, &self.permission.option_kind_len, request.option_kind);        self.permission.options = request.options;    }};fn copyTestField(destination: []u8, length: *usize, source: []const u8) !void {    if (source.len > destination.len) return error.TestJournalCapacity;    @memcpy(destination[0..source.len], source);    length.* = source.len;}/// A caller names the calling program to the agent, which reads these strings in the first message/// of the conversation. The structure holds the name, title and version the client reports about/// itself when it opens the conversation. The call `start` sends them in the `clientInfo` object of/// the `initialize` request. Every field has a default, so `.{}` reports the name "acp" and version/// "1.0.0". The strings are borrowed and must stay valid while `start` can run.pub const ClientInfo = struct {    /// The program's name, sent as `clientInfo.name`. The field defaults to "acp".    name: []const u8 = "acp",    /// The program's title, sent as `clientInfo.title`. The field defaults to "acp".    title: []const u8 = "acp",    /// The program's version, sent as `clientInfo.version`. The field defaults to "1.0.0".    version: []const u8 = "1.0.0",};/// A caller caps what the client writes to the agent and what it keeps from the agent, because the/// agent is another program and can send without end. The structure sets five caps: the size of/// each outgoing message, the size of each incoming update, the number of updates, and the size and/// chunk count of one prompt's reply. Every cap must be above zero, and `Client.init` returns/// `error.InvalidLimits` otherwise. `Options.transfer_limits` takes it and has no default. The/// reader's line limit in `Options.reader_limits` is separate and applies to every incoming line/// first.pub const TransferLimits = struct {    /// The largest message the client writes to the agent, in bytes, counting the newline that ends    /// it. The limit applies to every message the client writes: `initialize`, `session/new`,    /// prompts, permission answers, error answers and `session/cancel`. A larger message fails the    /// call with `error.RequestCapacityExceeded` and nothing is written.    request_bytes: usize,    /// The most reply text, in bytes, one prompt keeps. The chunk that would pass it stays out of    /// the reply, and the client sends `session/cancel`. The result then holds an overflow, and its    /// kind is `response_bytes`. The limit counts again from zero for each prompt.    response_bytes: usize,    /// The largest update message the client accepts, in bytes of the line after surrounding    /// whitespace is trimmed. A larger update fails the call with `error.UpdateCapacityExceeded`    /// before the observer sees it.    update_bytes: usize,    /// The most update messages one client accepts over its whole life. The update sequence starts    /// at 1 and never resets, so the first update numbered above this cap fails the call with    /// `error.UpdateCountCapacityExceeded`.    update_count: u64,    /// The most agent message chunks one prompt's reply keeps. The chunk past it stays out of the    /// reply, and the client sends `session/cancel`. The result then holds an overflow, and its    /// kind is `response_segments`.    response_segment_count: usize,    fn valid(self: TransferLimits) bool {        return self.request_bytes != 0 and self.response_bytes != 0 and            self.update_bytes != 0 and self.update_count != 0 and            self.response_segment_count != 0;    }};/// A caller states in one value passed to `Client.init` which program to start and how, and what/// limits, numbering and callbacks the conversation runs under. The structure provides the settings/// `Client.init` takes: the program and its arguments, directory and environment, the limits on/// reading and traffic, the caller's number for this client, and the caller's callbacks and lists./// `command`, `reader_limits`, `transfer_limits` and `transport_epoch` have no default./// `Client.init` copies the working directory and the list of arguments. The client keeps every/// other string, slice and the observer by reference, so each must stay valid while the client can/// use it.pub const Options = struct {    /// The program to run. The string becomes the first entry of the child's argument list, and the    /// field is required.    command: []const u8,    /// The longest line, in bytes, the client accepts from the agent. `Client.init` allocates the    /// reader's buffer, this limit plus one byte, before the agent starts.    /// `acp.default_reader_limits` sets 2 MiB. A longer line fails the call with    /// `error.ReaderMessageCapacityExceeded`, and the client reads nothing more from the agent.    reader_limits: reader.Limits,    /// The caps on traffic, described at `TransferLimits`. Every cap must be above zero.    transfer_limits: TransferLimits,    /// A number the caller picks for this client, stamped on every update it delivers. The value    /// must be nonzero, and `Client.init` returns `error.InvalidLimits` otherwise. Together with    /// the update sequence, this number names each update the client delivers. The caller records    /// each change of this number before starting the client.    transport_epoch: u64,    /// The arguments passed to the program after the command. The list defaults to none, and    /// `Client.init` reads it only while starting the program.    args: []const []const u8 = &.{},    /// The directory the agent runs in. `start` also names it to the agent in `session/new`. Null,    /// the default, leaves the agent in the caller's working directory, and `start` then sends the    /// caller's current directory. `Client.init` copies the string.    cwd: ?[]const u8 = null,    /// The name, title and version reported to the agent, described at `ClientInfo`. The field    /// defaults to the `ClientInfo` defaults.    client: ClientInfo = .{},    /// The MCP servers the agent is asked to start for the session, sent in `session/new` as    /// `mcpServers`. The slice defaults to none. The slice is borrowed, and the client reads it    /// each time `start` runs.    mcp_servers: []const McpServer = &.{},    /// The lists the client checks to answer a permission request when the observer gives no    /// answer. The default lists nothing, so every such request gets a reject option or the    /// `cancelled` outcome. The lists are borrowed for the client's life.    permission_policy: PermissionPolicy = .{},    /// The caller's callbacks for updates and permission decisions, described at `Observer`. The    /// field defaults to null. With no observer, the first update or permission request fails the    /// call with `error.MissingDurableObserver`.    observer: ?Observer = null,    /// The environment the agent starts with. Null, the default, passes the caller's current    /// environment.    environ: ?std.process.Environ = null,};/// A caller gives an MCP server its own environment, one variable at a time. The structure holds/// one environment variable, a name and a value, for an MCP server the agent starts. The client/// writes it as a `name` and `value` object in the server's `env` list in `session/new`. The/// variable sets nothing in the agent's own environment, which comes from `Options.environ`.pub const EnvVariable = struct {    /// The variable's name, sent as `name`.    name: []const u8,    /// The variable's value, sent as `value`.    value: []const u8,};/// A caller gives the agent extra tools for the session, which the agent gets by starting the/// listed tool servers. The structure describes one MCP (Model Context Protocol) server the agent/// is asked to start, given as a name, a command, arguments and environment. `start` sends each one/// in the `mcpServers` list of `session/new` as an object with `name`, `command`, `args` and `env`./// The package test writes a server that talks over standard input and output.pub const McpServer = struct {    /// The server's name, sent as `name`.    name: []const u8,    /// The program the agent runs for this server, sent as `command`.    command: []const u8,    /// The server's arguments, sent as the `args` list. The field defaults to none.    args: []const []const u8 = &.{},    /// Environment variables for the server, sent as the `env` list. The field defaults to none.    env: []const EnvVariable = &.{},};/// An observer that decides a permission request itself returns this value, so the client sends/// that decision to the agent. The structure holds the answer to one request from the agent for/// permission to run a tool call. The client copies each string before it uses it. The client sends/// the outcome and the option id to the agent, and records all three fields in the request the/// observer commits. The client does not check the option id against the options the agent offered.pub const PermissionReply = struct {    /// The outcome sent to the agent: the client uses `selected` when it picks an option and    /// `cancelled` when it picks none. The field is required.    outcome: []const u8,    /// The id of the chosen option, from the options the agent offered. The client sends it as    /// `optionId`, and leaves it out when empty. The field defaults to empty.    option_id: []const u8 = "",    /// The kind of the chosen option, such as `allow_once` or `reject_once`. The string is kept in    /// the committed record and never sent to the agent. The field defaults to empty.    option_kind: []const u8 = "",};/// The caller keeps the lasting record of the conversation and may answer permission requests/// itself, so the client calls out to it before it acts on each message. The structure provides a/// context pointer and three optional callbacks. The client calls them from inside `start` and the/// prompt calls, in the order the messages arrive. Each update goes to `updateFn` before its text/// joins the reply and before the client reads the next line. For a permission request,/// `permissionFn` may answer first, and `permissionCommitFn` then receives the decision before the/// client sends the answer to the agent. An error from `updateFn` or `permissionCommitFn` fails the/// client call that read the message. When `updateFn` or `permissionCommitFn` is null, the first/// message that needs it fails the call with `error.MissingDurableObserver`. The package README/// asks the observer to commit each exact update line before it returns.pub const Observer = struct {    /// The caller's context, passed as the first argument to every callback.    ptr: *anyopaque,    /// A callback called once per update, in arrival order, with the update's numbers, its exact    /// line and the fields read from it. The client frees the update and every string in it when    /// the call returns, so the callback copies what it keeps. Null, the default, fails the call    /// with `error.MissingDurableObserver` at the first update.    updateFn: ?*const fn (*anyopaque, protocol.Update) anyerror!void = null,    /// A callback asked first when the agent requests permission: a returned reply is used, and    /// null leaves the decision to the permission policy. The callback sees the request with    /// outcome `pending`, empty option fields and an empty line. Null, the default, leaves every    /// decision to the permission policy.    permissionFn: ?*const fn (*anyopaque, protocol.PermissionRequest) ?PermissionReply = null,    /// A callback called with the full request, the decision and the exact line, before the client    /// sends the answer to the agent. An error stops the answer from being sent and fails the call.    /// The client frees the request when the call returns, so the callback copies what it keeps.    /// Null, the default, fails the call with `error.MissingDurableObserver` at the first    /// permission request.    permissionCommitFn: ?*const fn (*anyopaque, protocol.PermissionRequest) anyerror!void = null,    fn update(self: Observer, value: protocol.Update) !void {        const callback = self.updateFn orelse return error.MissingDurableObserver;        try callback(self.ptr, value);    }    fn permission(self: Observer, request: protocol.PermissionRequest) ?PermissionReply {        const callback = self.permissionFn orelse return null;        return callback(self.ptr, request);    }    fn commitPermission(self: Observer, request: protocol.PermissionRequest) !void {        const callback = self.permissionCommitFn orelse            return error.MissingDurableObserver;        try callback(self.ptr, request);    }};/// A caller that trusts some tools lists them here, so the client grants those requests without/// asking the observer. The client checks two lists when the observer leaves a request unanswered:/// tool kinds and tool-title prefixes. A request passes when its tool call's kind equals a listed/// kind, or its title starts with a listed prefix. For a request that passes, the client picks the/// first `allow_once` option, else the first `allow_always` option. Otherwise, or when allow/// options are absent, the client picks the first `reject_once` option, else the last/// `reject_always` option, else answers `cancelled`. The default lists nothing, so it rejects every/// request.pub const PermissionPolicy = struct {    /// Tool kinds to grant, each compared whole against the tool call's `kind`. The package test    /// grants `execute`. The field defaults to none.    allowed_tool_kinds: []const []const u8 = &.{},    /// Title prefixes to grant, each compared against the start of the tool call's `title`. The    /// package test grants an MCP tool call titled `mcp__fixture__tool`, which carries no kind,    /// with the prefix `mcp__fixture__`. The field defaults to none.    allowed_title_prefixes: []const []const u8 = &.{},    fn allows(self: PermissionPolicy, object: std.json.ObjectMap) bool {        const params = json.objectObject(object, "params") orelse return false;        const tool_call = json.objectObject(params, "toolCall") orelse return false;        if (json.objectString(tool_call, "kind")) |kind| {            for (self.allowed_tool_kinds) |allowed| {                if (std.mem.eql(u8, allowed, kind)) return true;            }        }        if (json.objectString(tool_call, "title")) |title| {            for (self.allowed_title_prefixes) |prefix| {                if (std.mem.startsWith(u8, title, prefix)) return true;            }        }        return false;    }};/// A caller owns a client for the life of one agent program, to send it prompts and get replies/// back. The instance holds one agent process, the pipes to it, the reader for its output, and the/// state of one session. The order of use is `init`, `start`, any number of prompt calls, then/// `deinit`. The client writes each message to the agent as one line of JSON and reads the agent's/// messages one line at a time. Each call writes one request and then reads the agent's messages/// until the matching response arrives. While it waits, the call hands updates to the observer,/// answers permission requests, answers any other request from the agent with a "method not found"/// error, and drops other notifications. A line of invalid JSON, any other message, or the end of/// the agent's output before the response fails the call with `error.AgentProtocolError`. The/// fields hold the client's working state, and the package tests read `initialize`, `modes` and/// `child` directly.pub const Client = struct {    /// The allocator for everything the client owns: the argument list, the directory copy, the    /// reader's buffer, parsed messages, reply text and the session id.    allocator: std.mem.Allocator,    /// The argument list the agent started with: the command, then its arguments. The client owns    /// the list, and the strings in it are borrowed from `Options`.    argv: []const []const u8,    /// The client's own copy of `Options.cwd`, or null. The method `start` reads it for    /// `session/new`, and `deinit` frees it.    cwd: ?[]u8,    /// The name, title and version from `Options.client`, sent by `start`.    info: ClientInfo,    /// The MCP servers from `Options.mcp_servers`, sent by `start`.    mcp_servers: []const McpServer,    /// The permission lists from `Options.permission_policy`.    permission_policy: PermissionPolicy,    /// The callbacks from `Options.observer`, or null.    observer: ?Observer,    /// The traffic caps from `Options.transfer_limits`.    transfer_limits: TransferLimits,    /// The caller's number for this client from `Options.transport_epoch`, stamped on every update.    transport_epoch: u64,    /// The number the next update gets. The value starts at 1 and rises by one for each update, and    /// nothing resets it.    next_update_sequence: u64 = 1,    /// The Zig standard library's thread-based I/O (`std.Io.Threaded`) the client created for the    /// agent's process and pipes. The state carries the environment the agent started with. The    /// method `deinit` frees it.    io_state: sys.thread.ThreadedIo,    /// The agent process, with the pipes to its standard input and output. The agent runs in a    /// process group of its own, and its standard error is discarded.    child: sys.process.Child,    /// The line reader over the agent's standard output, which holds the reader's buffer.    reader: LineReader,    /// The id the next request gets. The value starts at 0 and rises by one per request, so    /// `initialize` gets 0, `session/new` gets 1, and the first prompt gets 2.    next_id: i64 = 0,    /// The agent's id for the current session, taken from the `session/new` answer. The field is    /// null before `start`, and every prompt call fails with `error.AgentProtocolError` while it is    /// null. The client owns it.    session_id: ?[]u8 = null,    /// The agent's answer to `initialize`, set by `start`. The field holds the empty defaults    /// before `start`. Callers read the protocol version, capability flags and authentication    /// methods from it.    initialize: protocol.Initialize = .{},    /// The session's modes from the `session/new` answer, set by `start`. The field is empty when    /// the agent reports no modes.    modes: protocol.Modes = .{},    /// The client's own copy of the message in the latest JSON-RPC error answer from the agent, or    /// null. Each later error answer replaces it, and a later success leaves it in place. Callers    /// read it through `agentError`.    agent_error: ?[]u8 = null,    /// A caller calls `init` to start the agent and prepare everything the conversation needs, so    /// later calls only exchange messages. The function checks the limits, allocates the reader's    /// buffer, copies the argument list and the working directory, then starts the agent. The    /// function returns `error.InvalidLimits` before it allocates anything when a traffic cap or    /// the transport epoch is zero. The call allocates the reader's buffer, the line limit plus one    /// byte, before the agent starts, so a limit too large to add one to fails with    /// `error.CapacityOverflow` and no process starts. The agent starts in a new process group of    /// its own, with pipes on standard input and output and standard error discarded. The call    /// returns allocation errors, errors from starting the process, and    /// `error.AgentTransportFailed` when the process has no output pipe. On an error after the    /// agent starts, the function signals the agent's process group to terminate and then to die,    /// and waits for the agent to exit. The call sends the agent no message: the conversation    /// begins with `start`. The caller owns the result and ends it with `deinit`.    pub fn init(allocator: std.mem.Allocator, options: Options) !Client {        if (!options.transfer_limits.valid() or options.transport_epoch == 0) {            return error.InvalidLimits;        }        var reader_storage = try reader.Storage.init(allocator, options.reader_limits);        errdefer reader_storage.deinit(allocator);        const argv = try allocator.alloc([]const u8, options.args.len + 1);        errdefer allocator.free(argv);        argv[0] = options.command;        @memcpy(argv[1..], options.args);        const cwd = if (options.cwd) |value| try allocator.dupe(u8, value) else null;        errdefer if (cwd) |value| allocator.free(value);        var io_state = sys.thread.initThreadedIo(allocator, .{ .environ = options.environ orelse sys.env.current() });        errdefer io_state.deinit();        const io = io_state.io();        var child = try sys.process.spawn(io, .{            .argv = argv,            .stdin = .pipe,            .stdout = .pipe,            .stderr = .ignore,            .cwd = if (cwd) |value| .{ .path = value } else .inherit,            .pgid = 0,        });        errdefer stopChildAndReap(&child, io);        const stdout = child.stdout orelse return error.AgentTransportFailed;        reader_storage.activate();        return .{            .allocator = allocator,            .argv = argv,            .cwd = cwd,            .info = options.client,            .mcp_servers = options.mcp_servers,            .permission_policy = options.permission_policy,            .observer = options.observer,            .transfer_limits = options.transfer_limits,            .transport_epoch = options.transport_epoch,            .io_state = io_state,            .child = child,            .reader = LineReader.init(io, stdout, reader_storage),        };    }    /// A caller calls `deinit` once to end the agent and free what the client owns when done with    /// the client. The function frees the reader's buffer, the stored `initialize` answer, the    /// modes, the error message, the session id and the directory copy. The client signals the    /// agent's whole process group to terminate and then to die, and waits for the agent to exit.    /// The client sends the agent no message first, `session/cancel` included. The call frees the    /// I/O instance and the argument list.    pub fn deinit(self: *Client) void {        self.reader.deinit(self.allocator);        self.initialize.deinit(self.allocator);        self.modes.deinit(self.allocator);        if (self.agent_error) |value| self.allocator.free(value);        if (self.session_id) |value| self.allocator.free(value);        if (self.cwd) |value| self.allocator.free(value);        stopChildAndReap(&self.child, self.io_state.io());        self.io_state.deinit();        self.allocator.free(self.argv);    }    /// A caller reads the status to learn why reading stopped after    /// `error.ReaderMessageCapacityExceeded`, or to watch how close the agent's lines come to the    /// limit. The call returns a snapshot of the reader's limit, buffer size, bytes waiting,    /// longest line seen, rejected lines, and whether reading has stopped. The package test reads    /// it after an overlong line.    pub fn readerStatus(self: *const Client) reader.Status {        return self.reader.storage.status();    }    /// A caller uses this call to stop the agent and everything it started at once. The client    /// signals the agent's whole process group to terminate and then to die, so tools the agent    /// started stop too. The call falls back to signalling the agent alone when signalling the    /// group fails. The function sends no `session/cancel` message: the client sends that message    /// only when a reply reaches a limit. The function returns without waiting for the agent to    /// exit, and `deinit` waits for it later. The call returns at once when the process has already    /// been reaped. A later call that reads from the agent finds its output closed and fails with    /// `error.AgentProtocolError`.    pub fn requestCancel(self: *Client) void {        stopChild(&self.child, self.io_state.io());    }    /// A caller calls `start` once after `init` and before the first prompt to open the    /// conversation. The call sends `initialize` with protocol version 1, an empty set of client    /// capabilities and the client's name, title and version, then waits for the answer. The    /// function fails with `error.AgentProtocolError` when the agent answers with an error, a    /// malformed result, or a protocol version other than 1. The client keeps the message of an    /// error answer for `agentError`. The call stores the answer in the `initialize` field. The    /// function then sends `session/new` with the working directory and the MCP servers, and stores    /// the session id and the modes from the answer. Updates and permission requests that arrive    /// meanwhile go through the observer as they do during a prompt. Calling it again runs both    /// steps again and replaces the stored answer, session id and modes. The call can also fail    /// with `error.RequestCapacityExceeded`, `error.ReaderMessageCapacityExceeded` and the errors    /// listed at `promptDetailedContent` for messages that arrive meanwhile.    pub fn start(self: *Client) !void {        const initialize_id = self.nextRequestId();        try self.writeInitialize(initialize_id);        var initialize_capture = PromptCapture{};        defer initialize_capture.deinit(self.allocator);        var initialized = try self.readResponse(initialize_id, &initialize_capture);        defer initialized.deinit();        _ = try self.responseResult(initialized.value);        var initialize = try protocol.Initialize.fromResponse(self.allocator, initialized.value);        errdefer initialize.deinit(self.allocator);        if (initialize.protocol_version != 1) return error.AgentProtocolError;        self.initialize.deinit(self.allocator);        self.initialize = initialize;        const session_id_request = self.nextRequestId();        if (self.cwd) |cwd| {            try self.writeSessionNew(session_id_request, cwd);        } else {            const cwd = try sys.fs.cwdAlloc(self.allocator);            defer self.allocator.free(cwd);            try self.writeSessionNew(session_id_request, cwd);        }        var session_capture = PromptCapture{};        defer session_capture.deinit(self.allocator);        var session = try self.readResponse(session_id_request, &session_capture);        defer session.deinit();        _ = try self.responseResult(session.value);        var modes = try protocol.Modes.fromResponse(self.allocator, session.value) orelse protocol.Modes{};        errdefer modes.deinit(self.allocator);        if (self.session_id) |value| {            self.allocator.free(value);            self.session_id = null;        }        self.session_id = try self.extractSessionId(session.value);        self.modes.deinit(self.allocator);        self.modes = modes;    }    /// A caller uses this call for the common case of sending text and getting the reply text back.    /// The call sends the text as one prompt and returns the reply: the text of the agent's message    /// chunks, joined in arrival order. The caller owns the returned bytes and frees them with the    /// client's allocator. The call drops the stop reason and any overflow record, which    /// `promptDetailed` returns. The function fails as `promptDetailedContent` does.    pub fn prompt(self: *Client, text: []const u8) ![]u8 {        var result = try self.promptDetailed(text);        const response = result.response;        result.response = &.{};        result.deinit(self.allocator);        return response;    }    /// A caller uses this call to send text and also learn why the agent stopped and whether the    /// reply was cut short. The call sends the text as one text block and returns the reply, the    /// stop reason and any overflow record. The caller frees the result with `PromptResult.deinit`    /// and the client's allocator. The function fails as `promptDetailedContent` does.    pub fn promptDetailed(self: *Client, text: []const u8) !protocol.PromptResult {        const content = [_]protocol.PromptContent{.{ .text = text }};        return try self.promptDetailedContent(content[0..]);    }    /// A caller uses this call to send a prompt that carries files, links or media alongside text.    /// The call sends one `session/prompt` request for the current session with the given blocks in    /// order. The function fails with `error.AgentProtocolError` before `start` has set a session.    /// The call reads until the prompt's response: each update goes to the observer, and agent    /// message chunks join the reply within the reply limits. When one more chunk would pass a    /// reply limit, the reply keeps what came before it, the result records the overflow, and the    /// client sends one `session/cancel` and keeps waiting for the agent's final response. The call    /// returns the reply, the agent's stop reason and the overflow record, and the caller frees the    /// result with `PromptResult.deinit`. The function fails with `error.RequestCapacityExceeded`    /// when the request is too large to send, and nothing is sent. The call fails with    /// `error.UpdateCapacityExceeded` or `error.UpdateCountCapacityExceeded` for an update past a    /// limit, `error.ReaderMessageCapacityExceeded` for a line past the line limit,    /// `error.MissingDurableObserver` when a callback it needs is missing, and any error an    /// observer callback returns. The call fails with `error.AgentProtocolError` for an error    /// answer, a malformed message, or a response without a `stopReason`. The call reads the blocks    /// and their strings only during the call.    pub fn promptDetailedContent(self: *Client, content: []const protocol.PromptContent) !protocol.PromptResult {        const session_id = self.session_id orelse return error.AgentProtocolError;        const prompt_id = self.nextRequestId();        try self.writePrompt(prompt_id, session_id, content);        var capture = PromptCapture{};        defer capture.deinit(self.allocator);        var response = try self.readResponse(prompt_id, &capture);        defer response.deinit();        const response_text = try capture.chunks.toOwnedSlice(self.allocator);        errdefer self.allocator.free(response_text);        const stop_reason = try self.extractPromptStop(response.value);        errdefer self.allocator.free(stop_reason);        return .{            .response = response_text,            .stop_reason = stop_reason,            .overflow = capture.overflow,        };    }    fn nextRequestId(self: *Client) i64 {        const id = self.next_id;        self.next_id += 1;        return id;    }    /// A caller uses this call to show what the agent said after a call fails with    /// `error.AgentProtocolError`. The call returns the message of the latest JSON-RPC error answer    /// from the agent, or null when there has been none. The message stays until a later error    /// answer replaces it, so a later success leaves it in place. When copying a new message fails    /// for lack of memory, the client keeps the older message. The slice belongs to the client and    /// stays valid until the next error answer or `deinit`.    pub fn agentError(self: *const Client) ?[]const u8 {        return self.agent_error;    }    fn recordAgentError(self: *Client, error_value: std.json.Value) void {        const object = json.getObject(error_value) orelse return;        const message = json.objectString(object, "message") orelse return;        const owned = self.allocator.dupe(u8, message) catch return;        if (self.agent_error) |previous| self.allocator.free(previous);        self.agent_error = owned;    }    fn responseResult(self: *Client, value: std.json.Value) !std.json.ObjectMap {        const object = json.getObject(value) orelse return error.AgentProtocolError;        if (object.get("error")) |error_value| {            self.recordAgentError(error_value);            return error.AgentProtocolError;        }        const result = object.get("result") orelse return error.AgentProtocolError;        return json.getObject(result) orelse return error.AgentProtocolError;    }    fn extractSessionId(self: *Client, value: std.json.Value) ![]u8 {        const result = try self.responseResult(value);        const session_id = json.objectString(result, "sessionId") orelse return error.AgentProtocolError;        return try self.allocator.dupe(u8, session_id);    }    fn extractPromptStop(self: *Client, value: std.json.Value) ![]u8 {        const result = try self.responseResult(value);        const reason = json.objectString(result, "stopReason") orelse return error.AgentProtocolError;        return try self.allocator.dupe(u8, reason);    }    fn writeInitialize(self: *Client, id: i64) !void {        var out: std.Io.Writer.Allocating = .init(self.allocator);        defer out.deinit();        var stream = pretty_json.Writer.init(&out.writer, .minified);        const root = try stream.object();        try root.field("jsonrpc", "2.0");        try root.field("id", id);        try root.field("method", "initialize");        const params = try root.object("params");        try params.field("protocolVersion", 1);        try params.field("clientCapabilities", .{});        const client_info = try params.object("clientInfo");        try client_info.field("name", self.info.name);        try client_info.field("title", self.info.title);        try client_info.field("version", self.info.version);        try client_info.end();        try params.end();        try root.endLine();        try self.send(out.written());    }    fn writeSessionNew(self: *Client, id: i64, cwd: []const u8) !void {        var out: std.Io.Writer.Allocating = .init(self.allocator);        defer out.deinit();        var stream = pretty_json.Writer.init(&out.writer, .minified);        const root = try stream.object();        try root.field("jsonrpc", "2.0");        try root.field("id", id);        try root.field("method", "session/new");        const params = try root.object("params");        try params.field("cwd", cwd);        try writeMcpServers(try params.array("mcpServers"), self.mcp_servers);        try params.end();        try root.endLine();        try self.send(out.written());    }    fn writePrompt(        self: *Client,        id: i64,        session_id: []const u8,        content: []const protocol.PromptContent,    ) !void {        var out: std.Io.Writer.Allocating = .init(self.allocator);        defer out.deinit();        var stream = pretty_json.Writer.init(&out.writer, .minified);        const root = try stream.object();        try root.field("jsonrpc", "2.0");        try root.field("id", id);        try root.field("method", "session/prompt");        const params = try root.object("params");        try params.field("sessionId", session_id);        try writePromptContent(try params.array("prompt"), content);        try params.end();        try root.endLine();        try self.send(out.written());    }    fn writePromptContent(array: pretty_json.Array, content: []const protocol.PromptContent) !void {        for (content) |item| try writePromptContentItem(array, item);        try array.end();    }    fn writePromptContentItem(array: pretty_json.Array, item: protocol.PromptContent) !void {        switch (item) {            .text => |text| {                const object = try array.object();                try object.field("type", "text");                try object.field("text", text);                try object.end();            },            .image => |media| try writePromptMedia(array, "image", media),            .audio => |media| try writePromptMedia(array, "audio", media),            .resource_text => |resource| try writePromptTextResource(array, resource),            .resource_blob => |resource| try writePromptBlobResource(array, resource),            .resource_link => |link| try writePromptResourceLink(array, link),        }    }    fn writePromptMedia(        array: pretty_json.Array,        content_type: []const u8,        media: protocol.PromptMedia,    ) !void {        const object = try array.object();        try object.field("type", content_type);        try object.field("mimeType", media.mime_type);        try object.field("data", media.data);        try writeOptionalStringField(object, "uri", media.uri);        try object.end();    }    fn writePromptTextResource(        array: pretty_json.Array,        resource: protocol.PromptTextResource,    ) !void {        const object = try array.object();        try object.field("type", "resource");        const value = try object.object("resource");        try value.field("uri", resource.uri);        try value.field("text", resource.text);        try writeOptionalStringField(value, "mimeType", resource.mime_type);        try value.end();        try object.end();    }    fn writePromptBlobResource(        array: pretty_json.Array,        resource: protocol.PromptBlobResource,    ) !void {        const object = try array.object();        try object.field("type", "resource");        const value = try object.object("resource");        try value.field("uri", resource.uri);        try value.field("blob", resource.blob);        try writeOptionalStringField(value, "mimeType", resource.mime_type);        try value.end();        try object.end();    }    fn writePromptResourceLink(array: pretty_json.Array, link: protocol.PromptResourceLink) !void {        const object = try array.object();        try object.field("type", "resource_link");        try object.field("uri", link.uri);        try object.field("name", link.name);        try writeOptionalStringField(object, "mimeType", link.mime_type);        try writeOptionalStringField(object, "title", link.title);        try writeOptionalStringField(object, "description", link.description);        if (link.size) |size| try object.field("size", size);        try object.end();    }    fn writeOptionalStringField(        object: pretty_json.Object,        name: []const u8,        value: []const u8,    ) !void {        if (value.len == 0) return;        try object.field(name, value);    }    fn writeErrorResponse(self: *Client, id_value: std.json.Value) !void {        var out: std.Io.Writer.Allocating = .init(self.allocator);        defer out.deinit();        var stream = pretty_json.Writer.init(&out.writer, .minified);        const root = try stream.object();        try root.field("jsonrpc", "2.0");        try root.field("id", id_value);        const error_value = try root.object("error");        try error_value.field("code", -32601);        try error_value.field("message", "method not found");        try error_value.end();        try root.endLine();        try self.send(out.written());    }    fn writePermissionResponse(        self: *Client,        id_value: std.json.Value,        selection: PermissionSelection,    ) !void {        var out: std.Io.Writer.Allocating = .init(self.allocator);        defer out.deinit();        var stream = pretty_json.Writer.init(&out.writer, .minified);        const root = try stream.object();        try root.field("jsonrpc", "2.0");        try root.field("id", id_value);        const result = try root.object("result");        const outcome = try result.object("outcome");        try outcome.field("outcome", selection.outcome);        try writeOptionalStringField(outcome, "optionId", selection.option_id);        try outcome.end();        try result.end();        try root.endLine();        try self.send(out.written());    }    fn writeCancel(self: *Client) !void {        const session_id = self.session_id orelse return error.AgentProtocolError;        var out: std.Io.Writer.Allocating = .init(self.allocator);        defer out.deinit();        var stream = pretty_json.Writer.init(&out.writer, .minified);        const root = try stream.object();        try root.field("jsonrpc", "2.0");        try root.field("method", "session/cancel");        const params = try root.object("params");        try params.field("sessionId", session_id);        try params.end();        try root.endLine();        try self.send(out.written());    }    fn send(self: *Client, bytes: []const u8) !void {        if (bytes.len > self.transfer_limits.request_bytes) {            return error.RequestCapacityExceeded;        }        const stdin = self.child.stdin orelse return error.AgentTransportFailed;        try fileWriteAll(self.io_state.io(), stdin, bytes);    }    fn selectPermission(self: *Client, object: std.json.ObjectMap) !OwnedSelection {        if (self.observer) |observer| {            if (observer.permissionFn != null) {                var preview = try protocol.PermissionRequest.fromClientRequest(                    self.allocator,                    object,                    "",                    "pending",                    "",                    "",                );                defer preview.deinit(self.allocator);                if (observer.permission(preview)) |reply| {                    var owned = OwnedSelection{ .allocator = self.allocator, .selection = .{ .outcome = "cancelled" } };                    errdefer owned.deinit();                    owned.outcome = try self.allocator.dupe(u8, reply.outcome);                    owned.option_id = try self.allocator.dupe(u8, reply.option_id);                    owned.option_kind = try self.allocator.dupe(u8, reply.option_kind);                    owned.selection = .{                        .outcome = owned.outcome.?,                        .option_id = owned.option_id.?,                        .option_kind = owned.option_kind.?,                    };                    return owned;                }            }        }        return .{ .allocator = self.allocator, .selection = permissionSelection(object, self.permission_policy) };    }    fn readResponse(self: *Client, id: i64, capture: *PromptCapture) !std.json.Parsed(std.json.Value) {        while (try self.reader.nextLine()) |line| {            const trimmed = std.mem.trim(u8, line, " \t\r\n");            if (trimmed.len == 0) continue;            var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, trimmed, .{}) catch return error.AgentProtocolError;            if (isResponseFor(parsed.value, id)) return parsed;            if (try self.handleIncoming(parsed.value, trimmed, capture)) {                parsed.deinit();                continue;            }            parsed.deinit();            return error.AgentProtocolError;        }        return error.AgentProtocolError;    }    fn handleIncoming(        self: *Client,        value: std.json.Value,        envelope: []const u8,        capture: *PromptCapture,    ) !bool {        const object = json.getObject(value) orelse return false;        if (json.objectString(object, "method")) |method| {            if (std.mem.eql(u8, method, "session/update")) {                const update_sequence = self.next_update_sequence;                if (update_sequence > self.transfer_limits.update_count) {                    return error.UpdateCountCapacityExceeded;                }                if (envelope.len > self.transfer_limits.update_bytes) {                    return error.UpdateCapacityExceeded;                }                self.next_update_sequence = std.math.add(                    u64,                    update_sequence,                    1,                ) catch return error.UpdateCountCapacityExceeded;                var update = (try protocol.Update.fromSessionNotification(                    self.allocator,                    object,                    self.transport_epoch,                    update_sequence,                    envelope,                )) orelse return error.AgentProtocolError;                defer update.deinit(self.allocator);                const observer = self.observer orelse                    return error.MissingDurableObserver;                try observer.update(update);                if (try capture.appendResponse(                    self.allocator,                    update,                    self.transfer_limits,                )) try self.writeCancel();                return true;            }            if (std.mem.eql(u8, method, "session/request_permission")) {                const id_value = object.get("id") orelse return false;                var selection = try self.selectPermission(object);                defer selection.deinit();                var request = try protocol.PermissionRequest.fromClientRequest(                    self.allocator,                    object,                    envelope,                    selection.selection.outcome,                    selection.selection.option_id,                    selection.selection.option_kind,                );                defer request.deinit(self.allocator);                const observer = self.observer orelse                    return error.MissingDurableObserver;                try observer.commitPermission(request);                try self.writePermissionResponse(id_value, selection.reply());                return true;            }            if (object.get("id")) |id_value| {                try self.writeErrorResponse(id_value);                return true;            }            return true;        }        return false;    }};const PermissionSelection = struct {    outcome: []const u8,    option_id: []const u8 = "",    option_kind: []const u8 = "",};const OwnedSelection = struct {    allocator: std.mem.Allocator,    selection: PermissionSelection,    outcome: ?[]u8 = null,    option_id: ?[]u8 = null,    option_kind: ?[]u8 = null,    fn reply(self: OwnedSelection) PermissionSelection {        return self.selection;    }    fn deinit(self: *OwnedSelection) void {        if (self.outcome) |value| self.allocator.free(value);        if (self.option_id) |value| self.allocator.free(value);        if (self.option_kind) |value| self.allocator.free(value);        self.* = undefined;    }};const PromptCapture = struct {    chunks: std.ArrayList(u8) = .empty,    response_segments: usize = 0,    overflow: ?protocol.Overflow = null,    fn deinit(self: *PromptCapture, allocator: std.mem.Allocator) void {        self.chunks.deinit(allocator);        self.* = undefined;    }    fn appendResponse(        self: *PromptCapture,        allocator: std.mem.Allocator,        update: protocol.Update,        limits: TransferLimits,    ) !bool {        if (!update.assistantMessage() or self.overflow != null) return false;        const next_segments = std.math.add(            usize,            self.response_segments,            1,        ) catch return self.setOverflow(.response_segments, update);        if (next_segments > limits.response_segment_count) {            return self.setOverflow(.response_segments, update);        }        const next_bytes = std.math.add(            usize,            self.chunks.items.len,            update.text.len,        ) catch return self.setOverflow(.response_bytes, update);        if (next_bytes > limits.response_bytes) {            return self.setOverflow(.response_bytes, update);        }        try self.chunks.appendSlice(allocator, update.text);        self.response_segments = next_segments;        return false;    }    fn setOverflow(        self: *PromptCapture,        kind: protocol.OverflowKind,        update: protocol.Update,    ) bool {        self.overflow = .{            .kind = kind,            .admitted_bytes = self.chunks.items.len,            .admitted_segments = self.response_segments,            .withheld_update_sequence = update.update_sequence,        };        return true;    }};fn permissionSelection(object: std.json.ObjectMap, policy: PermissionPolicy) PermissionSelection {    const params = json.objectObject(object, "params") orelse return .{ .outcome = "cancelled" };    const options_value = params.get("options") orelse return .{ .outcome = "cancelled" };    const options = switch (options_value) {        .array => |array| array,        else => return .{ .outcome = "cancelled" },    };    if (policy.allows(object)) {        if (permissionOption(options, "allow_once")) |selection| return selection;        if (permissionOption(options, "allow_always")) |selection| return selection;    }    if (permissionOption(options, "reject_once")) |selection| return selection;    var fallback: PermissionSelection = .{ .outcome = "cancelled" };    for (options.items) |item| {        const option = json.getObject(item) orelse continue;        const kind = json.objectString(option, "kind") orelse continue;        const option_id = json.objectString(option, "optionId") orelse continue;        if (std.mem.eql(u8, kind, "reject_always")) fallback = .{ .outcome = "selected", .option_id = option_id, .option_kind = kind };    }    return fallback;}fn permissionOption(options: std.json.Array, target_kind: []const u8) ?PermissionSelection {    for (options.items) |item| {        const option = json.getObject(item) orelse continue;        const kind = json.objectString(option, "kind") orelse continue;        if (!std.mem.eql(u8, kind, target_kind)) continue;        const option_id = json.objectString(option, "optionId") orelse continue;        return .{ .outcome = "selected", .option_id = option_id, .option_kind = kind };    }    return null;}fn stopChild(child: *sys.process.Child, io: anytype) void {    const child_id = child.id orelse return;    sys.process.signalChildGroup(child_id, .terminate) catch sys.process.requestTermination(child, io);    sys.process.signalChildGroup(child_id, .kill) catch sys.process.forceKillChildId(child_id);}fn stopChildAndReap(child: *sys.process.Child, io: anytype) void {    stopChild(child, io);    sys.process.killAndReap(child, io);}fn fileWriteAll(io: std.Io, file: std.Io.File, bytes: []const u8) !void {    var buf: [4096]u8 = undefined;    var writer = file.writer(io, &buf);    try writer.interface.writeAll(bytes);    try writer.interface.flush();}fn writeMcpServers(array: pretty_json.Array, servers: []const McpServer) !void {    for (servers) |server| {        const object = try array.object();        try object.field("name", server.name);        try object.field("command", server.command);        const args = try object.array("args");        for (server.args) |arg| try args.element(arg);        try args.end();        const env_values = try object.array("env");        for (server.env) |env| {            const value = try env_values.object();            try value.field("name", env.name);            try value.field("value", env.value);            try value.end();        }        try env_values.end();        try object.end();    }    try array.end();}fn isResponseFor(value: std.json.Value, id: i64) bool {    const object = json.getObject(value) orelse return false;    const actual = json.objectInteger(object, "id") orelse return false;    return actual == id and (object.get("result") != null or object.get("error") != null);}const LineReader = struct {    io: std.Io,    file: std.Io.File,    storage: reader.Storage,    fn init(io: std.Io, file: std.Io.File, storage: reader.Storage) LineReader {        return .{            .io = io,            .file = file,            .storage = storage,        };    }    fn deinit(self: *LineReader, allocator: std.mem.Allocator) void {        self.storage.deinit(allocator);    }    fn nextLine(self: *LineReader) !?[]const u8 {        while (true) {            switch (try self.storage.poll()) {                .line => |line| return stripLineEnding(line),                .end => return null,                .need_input => {},            }            var dest = [_][]u8{self.storage.writable()};            const bytes_read = self.file.readStreaming(self.io, &dest) catch |err| switch (err) {                error.EndOfStream => 0,                else => return err,            };            if (bytes_read == 0) {                self.storage.finish();            } else {                self.storage.commit(bytes_read);            }        }    }};fn stripLineEnding(line: []const u8) []const u8 {    if (line.len > 0 and line[line.len - 1] == '\r') return line[0 .. line.len - 1];    return line;}test "client writes structured ACP prompt content" {    var out: std.Io.Writer.Allocating = .init(std.testing.allocator);    defer out.deinit();    const content = [_]protocol.PromptContent{        .{ .text = "hello" },        .{ .resource_text = .{            .uri = "file:///tmp/context.zig",            .text = "const answer = 42;",            .mime_type = "text/zig",        } },        .{ .resource_link = .{            .uri = "file:///tmp/notes.md",            .name = "notes.md",            .mime_type = "text/markdown",            .title = "Notes",            .description = "user notes",            .size = 12,        } },    };    var stream = pretty_json.Writer.init(&out.writer, .minified);    try Client.writePromptContent(try stream.array(), content[0..]);    try std.testing.expectEqualStrings(        "[{\"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}]",        out.written(),    );}test "client collects ACP agent message chunks" {    const script =        \\prompt_count=0        \\while IFS= read -r line; do        \\  case "$line" in        \\    *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;        \\    *'"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"}]}}}' ;;        \\    *'"method":"session/prompt"'*)        \\      prompt_count=$((prompt_count + 1))        \\      if [ "$prompt_count" -eq 1 ]; then        \\        printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"{\"decision\":\"accept\"}"}}}}'        \\        printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'        \\      fi        \\      ;;        \\  esac        \\done    ;    var journal = TestJournal{};    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = reader.default_limits,        .transfer_limits = test_transfer_limits,        .transport_epoch = 1,        .args = &.{ "-c", script },        .client = .{ .name = "acp-test", .title = "acp-test" },        .observer = journal.observer(),    });    defer client.deinit();    try client.start();    try std.testing.expectEqual(@as(i64, 1), client.initialize.protocol_version);    const response = try client.prompt("hello");    defer std.testing.allocator.free(response);    try std.testing.expectEqualStrings("{\"decision\":\"accept\"}", response);}test "client preserves ACP initialize capabilities" {    const script =        \\while IFS= read -r line; do        \\  case "$line" in        \\    *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"embeddedContent":true}},"authMethods":[{"id":"token"}]}}' ;;        \\    *'"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"}]}}}' ;;        \\  esac        \\done    ;    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = reader.default_limits,        .transfer_limits = test_transfer_limits,        .transport_epoch = 1,        .args = &.{ "-c", script },        .client = .{ .name = "acp-test", .title = "acp-test" },    });    defer client.deinit();    try client.start();    try std.testing.expectEqual(@as(i64, 1), client.initialize.protocol_version);    try std.testing.expect(client.initialize.load_session);    try std.testing.expect(client.initialize.prompt_image);    try std.testing.expect(client.initialize.prompt_embedded_content);    try std.testing.expectEqual(@as(usize, 1), client.initialize.auth_methods);    try std.testing.expectEqualStrings("[\"token\"]", client.initialize.auth_method_ids);    try std.testing.expectEqualStrings("plan", client.modes.current);    try std.testing.expectEqual(@as(usize, 2), client.modes.available);}test "client preserves ACP session updates" {    const script =        \\while IFS= read -r line; do        \\  case "$line" in        \\    *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;        \\    *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;        \\    *'"method":"session/prompt"'*)        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"thinking"}}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"plan","entries":[{"content":"inspect","status":"pending"}]}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"done"}}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'        \\      ;;        \\  esac        \\done    ;    var journal = TestJournal{};    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = reader.default_limits,        .transfer_limits = test_transfer_limits,        .transport_epoch = 1,        .args = &.{ "-c", script },        .client = .{ .name = "acp-test", .title = "acp-test" },        .observer = journal.observer(),    });    defer client.deinit();    try client.start();    var result = try client.promptDetailed("hello");    defer result.deinit(std.testing.allocator);    try std.testing.expectEqualStrings("done", result.response);    try std.testing.expectEqualStrings("end_turn", result.stop_reason);    try std.testing.expectEqual(@as(usize, 3), journal.update_count);    try std.testing.expectEqual(protocol.UpdateKind.agent_thought_chunk, journal.update_kinds[0]);    try std.testing.expectEqualStrings("thinking", journal.update_text[0][0..journal.update_text_lengths[0]]);    try std.testing.expectEqual(protocol.UpdateKind.plan, journal.update_kinds[1]);    try std.testing.expectEqualStrings("inspect", journal.update_text[1][0..journal.update_text_lengths[1]]);    try std.testing.expectEqualSlices(u64, &.{ 1, 2, 3 }, journal.update_sequences[0..3]);}const FixtureJournal = struct {    expected: []const []const u8,    index: usize = 0,    transport_epoch: u64,    fn onUpdate(context: *anyopaque, update: protocol.Update) !void {        const self: *FixtureJournal = @ptrCast(@alignCast(context));        if (self.index >= self.expected.len) return error.UnexpectedUpdate;        try std.testing.expectEqual(self.transport_epoch, update.transport_epoch);        try std.testing.expectEqual(self.index + 1, update.update_sequence);        try std.testing.expectEqualStrings(self.expected[self.index], update.envelope);        self.index += 1;    }};test "client frames every official ACP v1 session update variant exactly once" {    const fixtures = [_][]const u8{        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"user_message_chunk\",\"content\":{\"type\":\"text\",\"text\":\"user\"}}}}",        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"agent_message_chunk\",\"content\":{\"type\":\"text\",\"text\":\"agent\"}}}}",        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"agent_thought_chunk\",\"content\":{\"type\":\"text\",\"text\":\"thought\"}}}}",        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"call-1\",\"title\":\"Read\",\"kind\":\"read\",\"status\":\"pending\",\"content\":[]}}}",        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"tool_call_update\",\"toolCallId\":\"call-1\",\"status\":\"completed\",\"content\":[]}}}",        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"plan\",\"entries\":[]}}}",        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"available_commands_update\",\"availableCommands\":[]}}}",        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"current_mode_update\",\"currentModeId\":\"code\"}}}",        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"config_option_update\",\"configOptions\":[]}}}",        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"session_info_update\",\"title\":\"Session\",\"updatedAt\":\"2026-08-15T00:00:00Z\"}}}",        "{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"acp-test\",\"update\":{\"sessionUpdate\":\"usage_update\",\"used\":1,\"size\":2}}}",    };    const script =        \\while IFS= read -r line; do        \\  case "$line" in        \\    *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;        \\    *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;        \\    *'"method":"session/prompt"'*)        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"user"}}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"agent"}}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"thought"}}}}'        \\      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":[]}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","content":[]}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"plan","entries":[]}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"current_mode_update","currentModeId":"code"}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"config_option_update","configOptions":[]}}}'        \\      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"}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"usage_update","used":1,"size":2}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'        \\      ;;        \\  esac        \\done    ;    var journal = FixtureJournal{        .expected = &fixtures,        .transport_epoch = 9,    };    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = reader.default_limits,        .transfer_limits = test_transfer_limits,        .transport_epoch = 9,        .args = &.{ "-c", script },        .observer = .{ .ptr = &journal, .updateFn = FixtureJournal.onUpdate },    });    defer client.deinit();    try client.start();    var result = try client.promptDetailed("fixture");    defer result.deinit(std.testing.allocator);    try std.testing.expectEqual(fixtures.len, journal.index);    try std.testing.expectEqualStrings("agent", result.response);}test "client rejects and records ACP permission callbacks" {    const script =        \\while IFS= read -r line; do        \\  case "$line" in        \\    *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;        \\    *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;        \\    *'"method":"session/prompt"'*)        \\      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"}]}}'        \\      ;;        \\    *'"id":"perm-1"'*'"outcome":"selected"'*'"optionId":"reject"'*)        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"denied"}}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'        \\      ;;        \\  esac        \\done    ;    var journal = TestJournal{};    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = reader.default_limits,        .transfer_limits = test_transfer_limits,        .transport_epoch = 1,        .args = &.{ "-c", script },        .client = .{ .name = "acp-test", .title = "acp-test" },        .observer = journal.observer(),    });    defer client.deinit();    try client.start();    var result = try client.promptDetailed("hello");    defer result.deinit(std.testing.allocator);    try std.testing.expectEqualStrings("denied", result.response);    try std.testing.expectEqual(@as(usize, 1), journal.permission_count);    try std.testing.expectEqualStrings("acp-test", journal.permission.session_id[0..journal.permission.session_id_len]);    try std.testing.expectEqualStrings("tc1", journal.permission.tool_call_id[0..journal.permission.tool_call_id_len]);    try std.testing.expectEqualStrings("run command", journal.permission.title[0..journal.permission.title_len]);    try std.testing.expectEqualStrings("execute", journal.permission.tool_kind[0..journal.permission.tool_kind_len]);    try std.testing.expectEqualStrings("pending", journal.permission.status[0..journal.permission.status_len]);    try std.testing.expectEqualStrings("selected", journal.permission.outcome[0..journal.permission.outcome_len]);    try std.testing.expectEqualStrings("reject", journal.permission.option_id[0..journal.permission.option_id_len]);    try std.testing.expectEqualStrings("reject_once", journal.permission.option_kind[0..journal.permission.option_kind_len]);    try std.testing.expectEqual(@as(usize, 2), journal.permission.options);}test "client allows granted ACP permission callbacks" {    const script =        \\while IFS= read -r line; do        \\  case "$line" in        \\    *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;        \\    *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;        \\    *'"method":"session/prompt"'*)        \\      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"}]}}'        \\      ;;        \\    *'"id":"perm-1"'*'"outcome":"selected"'*'"optionId":"allow"'*)        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"allowed"}}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'        \\      ;;        \\  esac        \\done    ;    var journal = TestJournal{};    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = reader.default_limits,        .transfer_limits = test_transfer_limits,        .transport_epoch = 1,        .args = &.{ "-c", script },        .client = .{ .name = "acp-test", .title = "acp-test" },        .permission_policy = .{ .allowed_tool_kinds = &.{"execute"} },        .observer = journal.observer(),    });    defer client.deinit();    try client.start();    var result = try client.promptDetailed("hello");    defer result.deinit(std.testing.allocator);    try std.testing.expectEqualStrings("allowed", result.response);    try std.testing.expectEqual(@as(usize, 1), journal.permission_count);    try std.testing.expectEqualStrings("selected", journal.permission.outcome[0..journal.permission.outcome_len]);    try std.testing.expectEqualStrings("allow", journal.permission.option_id[0..journal.permission.option_id_len]);    try std.testing.expectEqualStrings("allow_once", journal.permission.option_kind[0..journal.permission.option_kind_len]);}test "client allows granted MCP tool titles without a kind" {    const script =        \\while IFS= read -r line; do        \\  case "$line" in        \\    *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;        \\    *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;        \\    *'"method":"session/prompt"'*)        \\      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"}]}}'        \\      ;;        \\    *'"id":"perm-1"'*'"outcome":"selected"'*'"optionId":"allow"'*)        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"granted"}}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}'        \\      ;;        \\  esac        \\done    ;    var journal = TestJournal{};    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = reader.default_limits,        .transfer_limits = test_transfer_limits,        .transport_epoch = 1,        .args = &.{ "-c", script },        .client = .{ .name = "acp-test", .title = "acp-test" },        .permission_policy = .{ .allowed_title_prefixes = &.{"mcp__fixture__"} },        .observer = journal.observer(),    });    defer client.deinit();    try client.start();    var result = try client.promptDetailed("hello");    defer result.deinit(std.testing.allocator);    try std.testing.expectEqualStrings("granted", result.response);    try std.testing.expectEqual(@as(usize, 1), journal.permission_count);    try std.testing.expectEqualStrings("selected", journal.permission.outcome[0..journal.permission.outcome_len]);    try std.testing.expectEqualStrings("allow", journal.permission.option_id[0..journal.permission.option_id_len]);    try std.testing.expectEqualStrings("allow_once", journal.permission.option_kind[0..journal.permission.option_kind_len]);}const UpdateCounter = struct {    count: usize = 0,    fn onUpdate(context: *anyopaque, _: protocol.Update) !void {        const self: *UpdateCounter = @ptrCast(@alignCast(context));        self.count +|= 1;    }};test "client admits reader capacity before spawning the agent" {    comptime {        @stardustClaim(            @import("alloc_phase").capacity.witness(@import("./reader/root.zig").Storage, "acp_reader_admission"),            null,            null,            null,            null,            null,            null,        );    }    try std.testing.expectError(        error.CapacityOverflow,        Client.init(std.testing.allocator, .{            .command = "/does/not/exist",            .reader_limits = .{ .message_bytes = std.math.maxInt(usize) },            .transfer_limits = test_transfer_limits,            .transport_epoch = 1,        }),    );}test "client rejects oversized ACP output before parsing" {    comptime {        @stardustClaim(            @import("alloc_phase").capacity.witness(@import("./reader/root.zig").Storage, "acp_reader_client_terminal_overload"),            null,            null,            null,            null,            null,            null,        );    }    comptime {        @stardustClaim(            @import("alloc_phase").capacity.witness(@import("./reader/root.zig").Storage, "acp_reader_client_terminal_foreign_risk"),            null,            null,            null,            null,            null,            null,        );    }    const script =        \\while IFS= read -r line; do        \\  printf '%s' '{"jsonrpc":"2.0","method":"session/update",'        \\  printf '%s' '"params":{"sessionId":"acp-test","update":'        \\  printf '%s' '{"sessionUpdate":"agent_message_chunk",'        \\  printf '%s\n' '"content":{"type":"text","text":"oversized"}}}}'        \\done    ;    var updates = UpdateCounter{};    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = .{ .message_bytes = 32 },        .transfer_limits = test_transfer_limits,        .transport_epoch = 1,        .args = &.{ "-c", script },        .observer = .{ .ptr = &updates, .updateFn = UpdateCounter.onUpdate },    });    defer client.deinit();    try std.testing.expectError(error.ReaderMessageCapacityExceeded, client.start());    try std.testing.expectEqual(@as(usize, 0), updates.count);    try std.testing.expectEqual(reader.Status{        .phase = .steady,        .message_bytes = 32,        .storage_bytes = 33,        .buffered_bytes = 33,        .high_water_message_bytes = 32,        .rejected_message_count = 1,        .terminal = true,    }, client.readerStatus());}test "client refuses an oversized request before forwarding" {    const script =        \\while IFS= read -r line; do        \\  case "$line" in        \\    *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;        \\    *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;        \\    *'"method":"session/prompt"'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"forwarded"}}' ;;        \\  esac        \\done    ;    var limits = test_transfer_limits;    limits.request_bytes = 512;    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = reader.default_limits,        .transfer_limits = limits,        .transport_epoch = 1,        .args = &.{ "-c", script },    });    defer client.deinit();    try client.start();    var request: [1024]u8 = @splat('x');    try std.testing.expectError(        error.RequestCapacityExceeded,        client.prompt(&request),    );}test "client commits admitted response segments and settles overflow" {    const script =        \\while IFS= read -r line; do        \\  case "$line" in        \\    *'"method":"initialize"'*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;        \\    *'"method":"session/new"'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"acp-test"}}' ;;        \\    *'"method":"session/prompt"'*)        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ok"}}}}'        \\      printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp-test","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"no"}}}}'        \\      ;;        \\    *'"method":"session/cancel"'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"stopReason":"cancelled"}}' ;;        \\  esac        \\done    ;    var limits = test_transfer_limits;    limits.response_bytes = 3;    var journal = TestJournal{};    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = reader.default_limits,        .transfer_limits = limits,        .transport_epoch = 3,        .args = &.{ "-c", script },        .observer = journal.observer(),    });    defer client.deinit();    try client.start();    var result = try client.promptDetailed("overflow");    defer result.deinit(std.testing.allocator);    try std.testing.expectEqualStrings("ok", result.response);    try std.testing.expectEqualStrings("cancelled", result.stop_reason);    try std.testing.expectEqual(@as(usize, 2), journal.update_count);    const overflow = result.overflow orelse return error.ExpectedOverflow;    try std.testing.expectEqual(protocol.OverflowKind.response_bytes, overflow.kind);    try std.testing.expectEqual(@as(usize, 2), overflow.admitted_bytes);    try std.testing.expectEqual(@as(usize, 1), overflow.admitted_segments);    try std.testing.expectEqual(@as(u64, 2), overflow.withheld_update_sequence);}test "client cancellation kills process group" {    if (sys.process.childSignalPolicy() == .unsupported) return error.SkipZigTest;    var client = try Client.init(std.testing.allocator, .{        .command = "/bin/sh",        .reader_limits = reader.default_limits,        .transfer_limits = test_transfer_limits,        .transport_epoch = 1,        .args = &.{ "-c", "trap '' TERM; while true; do sleep 5 & wait; done" },    });    defer client.deinit();    const child_id = client.child.id orelse return error.ExpectedChild;    client.requestCancel();    const deadline = sys.time.nanoTimestamp() + 2 * std.time.ns_per_s;    while (sys.time.nanoTimestamp() < deadline) {        if (try sys.process.waitNoHang(child_id)) |_| {            client.child.id = null;            return;        }        sys.thread.yield();    }    return error.ExpectedChildExit;}test "client serializes stdio MCP servers for session setup" {    var out: std.Io.Writer.Allocating = .init(std.testing.allocator);    defer out.deinit();    var stream = pretty_json.Writer.init(&out.writer, .minified);    try writeMcpServers(try stream.array(), &.{        .{            .name = "sample",            .command = "/bin/sample-mcp",            .args = &.{ "serve", "stdio" },            .env = &.{.{ .name = "SAMPLE_SESSION", .value = "session.jsonl" }},        },    });    const expected =        "[{\"name\":\"sample\",\"command\":\"/bin/sample-mcp\"," ++        "\"args\":[\"serve\",\"stdio\"],\"env\":[" ++        "{\"name\":\"SAMPLE_SESSION\",\"value\":\"session.jsonl\"}]}]";    try std.testing.expectEqualStrings(expected, out.written());}

Source: lib/acp/src/root.zig:62

zig
pub const client = @import("client.zig");

Audit

Definitions1
Public names1
Members0
Version26.7.0
Revisiondaab053ee433