lib/acp/src/protocol.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 //! Zig shapes for the Agent Client Protocol messages the client reads and writes: the agent's
  2 //! answer to `initialize`, the session's modes, the blocks a prompt carries, the progress reports
  3 //! the agent sends, its permission requests, and the result of a prompt.
  4 //!
  5 //! A caller has to read what the agent reported without walking the JSON itself, and it has to keep
  6 //! that data after the parsed message is gone.
  7 //!
  8 //! The messages carry many optional fields, and an agent can send kinds of progress report
  9 //! unfamiliar to the code.
 10 //!
 11 //! The fields follow the protocol's JSON keys, such as `protocolVersion`, `agentCapabilities`,
 12 //! `sessionUpdate`, `toolCall` and `stopReason`. Prompt blocks are written with the protocol's type
 13 //! names `text`, `image`, `audio`, `resource` and `resource_link`.
 14 //!
 15 //! Each parsed shape copies the strings it keeps with the caller's allocator, so it outlives the
 16 //! parsed JSON, and its `deinit` frees them. Each parsed shape breaks out a few fields as typed
 17 //! values and keeps a minified JSON copy (*raw copy*) of the rest of its part of the message. A
 18 //! missing optional string reads as an empty slice, a missing flag as false, a missing list as a
 19 //! count of 0, and a progress report of an unknown kind as `other`. When the client writes a prompt
 20 //! block, it leaves out every optional string that is empty. The one borrowed field is the exact
 21 //! line the agent sent (*envelope*), in `Update` and `PermissionRequest`: it points into the
 22 //! reader's buffer and is valid only while the caller's callback runs.
 23 //!
 24 //! - *observer*: the caller's callbacks for updates and permission decisions
 25 //! - *summary text*: one short string picked from an update's fields
 26 //! - *transport epoch*: a nonzero number the caller picks for one client
 27 //! - *update sequence*: an update's number, counted from 1 in arrival order
 28 //! - *overflow*: a record that a reply stopped at a limit
 29 //! - *permission policy*: two caller lists of tool kinds and title prefixes to allow
 30 const std = @import("std");
 31 const pretty = @import("pretty");
 32 const json = @import("json.zig");
 33 
 34 const Allocator = std.mem.Allocator;
 35 const pretty_json = pretty.json;
 36 
 37 /// A caller uses this structure to learn which protocol version the agent speaks, which kinds of
 38 /// prompt block it accepts, and how it authenticates. The structure holds the agent's answer to
 39 /// `initialize`: protocol version, capability flags, authentication methods, and a raw copy of the
 40 /// whole result. `Client.start` fills the structure and keeps it in `Client.initialize`. Every
 41 /// field has a default, and the default value stands for an absent answer. The structure owns its
 42 /// two strings, and `deinit` frees them.
 43 pub const Initialize = struct {
 44     /// The protocol version the agent answered with, from `result.protocolVersion`. `Client.start`
 45     /// accepts only 1. The field defaults to 0.
 46     protocol_version: i64 = 0,
 47     /// The agent's `agentCapabilities.loadSession` flag. The value is false when the key is absent.
 48     load_session: bool = false,
 49     /// The agent's `promptCapabilities.audio` flag, for prompts that carry audio blocks. The value
 50     /// is false when the key is absent.
 51     prompt_audio: bool = false,
 52     /// The agent's `promptCapabilities.image` flag, for prompts that carry image blocks. The value
 53     /// is false when the key is absent.
 54     prompt_image: bool = false,
 55     /// Reports true when `promptCapabilities` sets either `embeddedContent` or `embeddedContext` to
 56     /// true. The value is false when both keys are absent.
 57     prompt_embedded_content: bool = false,
 58     /// The number of entries in the answer's `authMethods` list. The value is 0 when the key is
 59     /// absent or holds a non-list value.
 60     auth_methods: usize = 0,
 61     /// The ids of the authentication methods as one minified JSON array of strings, such as
 62     /// `["token"]`. A method lacking a string `id` is skipped, and the slice is empty when all
 63     /// methods lack one. The slice is owned.
 64     auth_method_ids: []u8 = &.{},
 65     /// A minified JSON copy of the whole `result` object. The slice is owned.
 66     raw: []u8 = &.{},
 67 
 68     /// A caller uses this function to free the answer once the caller is done with it. The function
 69     /// frees the two owned strings with the given allocator and leaves the value undefined. The
 70     /// call is safe on the default value, which owns zero allocations. The caller provides the
 71     /// allocator that `fromResponse` used.
 72     pub fn deinit(self: *Initialize, allocator: Allocator) void {
 73         if (self.auth_method_ids.len != 0) allocator.free(self.auth_method_ids);
 74         if (self.raw.len != 0) allocator.free(self.raw);
 75         self.* = undefined;
 76     }
 77 
 78     /// A caller uses this function to turn a parsed `initialize` response into typed fields, as
 79     /// `Client.start` does. The function reads a parsed JSON-RPC response and returns its result as
 80     /// an `Initialize`. The function fails with `error.AgentProtocolError` when the value is a
 81     /// non-object, carries an `error` member, lacks an object `result`, or the result lacks an
 82     /// integer `protocolVersion`. The parser reads a missing capability flag as false. The call
 83     /// copies the strings it keeps with the allocator, so the result outlives the parsed JSON, and
 84     /// the caller frees it with `deinit`. The function fails with `error.OutOfMemory` and leaves
 85     /// zero allocations when a copy fails.
 86     pub fn fromResponse(allocator: Allocator, value: std.json.Value) !Initialize {
 87         const result = try responseResult(value);
 88         const protocol_version = json.objectInteger(result, "protocolVersion") orelse return error.AgentProtocolError;
 89         const raw = try rawJsonAlloc(allocator, result);
 90         errdefer if (raw.len != 0) allocator.free(raw);
 91         const auth_method_ids = try authMethodIdsAlloc(allocator, result);
 92         errdefer if (auth_method_ids.len != 0) allocator.free(auth_method_ids);
 93         var load_session = false;
 94         var prompt_audio = false;
 95         var prompt_image = false;
 96         var prompt_embedded_content = false;
 97         if (json.objectObject(result, "agentCapabilities")) |capabilities| {
 98             load_session = json.objectBool(capabilities, "loadSession") orelse false;
 99             if (json.objectObject(capabilities, "promptCapabilities")) |prompt| {
100                 prompt_audio = json.objectBool(prompt, "audio") orelse false;
101                 prompt_image = json.objectBool(prompt, "image") orelse false;
102                 prompt_embedded_content = (json.objectBool(prompt, "embeddedContent") orelse false) or (json.objectBool(prompt, "embeddedContext") orelse false);
103             }
104         }
105         return .{
106             .protocol_version = protocol_version,
107             .load_session = load_session,
108             .prompt_audio = prompt_audio,
109             .prompt_image = prompt_image,
110             .prompt_embedded_content = prompt_embedded_content,
111             .auth_methods = arrayLength(result, "authMethods"),
112             .auth_method_ids = auth_method_ids,
113             .raw = raw,
114         };
115     }
116 };
117 
118 /// A caller uses this structure to learn which mode the session starts in, such as `plan` or `act`,
119 /// and how many modes the agent offers. The structure holds the session's current mode id, the
120 /// number of modes on offer, and a raw copy of the `modes` object from the `session/new` answer.
121 /// `Client.start` keeps the structure in `Client.modes`, and the value stays empty when the agent
122 /// reports zero modes. The structure owns `current` and `raw`, and `deinit` frees them.
123 pub const Modes = struct {
124     /// The current mode's id, from `modes.currentModeId`. The slice is owned, and empty by default.
125     current: []u8 = &.{},
126     /// The number of entries in `modes.availableModes`. The count is 0 by default and when the key
127     /// is absent.
128     available: usize = 0,
129     /// A minified JSON copy of the `modes` object. The slice is owned, and empty by default.
130     raw: []u8 = &.{},
131 
132     /// A caller uses this function to free the modes once the caller is done with them. The
133     /// function frees the two owned strings with the given allocator and leaves the value
134     /// undefined. The call is safe on the default value, which owns zero allocations.
135     pub fn deinit(self: *Modes, allocator: Allocator) void {
136         if (self.current.len != 0) allocator.free(self.current);
137         if (self.raw.len != 0) allocator.free(self.raw);
138         self.* = undefined;
139     }
140 
141     /// A caller uses this function to read the session's modes from a parsed `session/new`
142     /// response, as `Client.start` does. The function reads a parsed JSON-RPC response and returns
143     /// the modes in its result. The function returns null when the result lacks a `modes` object or
144     /// that object lacks a string `currentModeId`. The function fails with
145     /// `error.AgentProtocolError` when the value is a non-object, carries an `error` member, or
146     /// lacks an object `result`. The call copies the strings it keeps with the allocator, and the
147     /// caller frees them with `deinit`.
148     pub fn fromResponse(allocator: Allocator, value: std.json.Value) !?Modes {
149         const result = try responseResult(value);
150         const modes = json.objectObject(result, "modes") orelse return null;
151         const current = json.objectString(modes, "currentModeId") orelse return null;
152         const owned_current = try allocator.dupe(u8, current);
153         errdefer allocator.free(owned_current);
154         const raw = try rawJsonAlloc(allocator, modes);
155         errdefer if (raw.len != 0) allocator.free(raw);
156         return .{
157             .current = owned_current,
158             .available = arrayLength(modes, "availableModes"),
159             .raw = raw,
160         };
161     }
162 };
163 
164 /// A caller uses this structure to put an image or an audio clip in a prompt. The structure holds
165 /// an image or an audio clip for a prompt: its MIME type, its data, and an optional URI. The client
166 /// writes it as a block of type `image` or `audio` with `mimeType`, `data`, and `uri` when one is
167 /// set. The strings are borrowed and read only while the prompt is written.
168 pub const PromptMedia = struct {
169     /// The media's MIME type, sent as `mimeType`. The field is required.
170     mime_type: []const u8,
171     /// The media's content, sent unchanged as the JSON string `data`. The field is required.
172     data: []const u8,
173     /// The media's URI, sent as `uri`. The field is empty by default, and an empty URI is left out.
174     uri: []const u8 = "",
175 };
176 
177 /// A caller uses this structure to put a file's text in the prompt, such as source code the agent
178 /// should read. The structure holds a text resource embedded in a prompt: its URI, its text, and an
179 /// optional MIME type. The client writes it as a block of type `resource` whose `resource` object
180 /// holds `uri`, `text`, and `mimeType` when one is set.
181 pub const PromptTextResource = struct {
182     /// The resource's URI, sent as `resource.uri`. The field is required.
183     uri: []const u8,
184     /// The resource's text, sent as `resource.text`. The field is required.
185     text: []const u8,
186     /// The resource's MIME type, sent as `resource.mimeType`. The field is empty by default, and an
187     /// empty value is left out.
188     mime_type: []const u8 = "",
189 };
190 
191 /// A caller uses this structure to put a resource's content in the prompt as a blob string. The
192 /// structure holds a blob resource embedded in a prompt: its URI, its content as a string, and an
193 /// optional MIME type. The client writes it as a block of type `resource` whose `resource` object
194 /// holds `uri`, `blob`, and `mimeType` when one is set.
195 pub const PromptBlobResource = struct {
196     /// The resource's URI, sent as `resource.uri`. The field is required.
197     uri: []const u8,
198     /// The resource's content, sent unchanged as the JSON string `resource.blob`. The field is
199     /// required.
200     blob: []const u8,
201     /// The resource's MIME type, sent as `resource.mimeType`. The field is empty by default, and an
202     /// empty value is left out.
203     mime_type: []const u8 = "",
204 };
205 
206 /// A caller uses this structure to point the agent at a resource by URI without putting its content
207 /// in the prompt. The structure holds a link to a resource: a URI and a name, with an optional MIME
208 /// type, title, description and size. The client writes it as a block of type `resource_link` with
209 /// each field that is set. Empty strings and a null size are left out.
210 pub const PromptResourceLink = struct {
211     /// The resource's URI, sent as `uri`. The field is required.
212     uri: []const u8,
213     /// The resource's name, sent as `name`. The field is required.
214     name: []const u8,
215     /// The resource's MIME type, sent as `mimeType`. The field is empty by default, and an empty
216     /// value is left out.
217     mime_type: []const u8 = "",
218     /// A title for the resource, sent as `title`. The field is empty by default, and an empty value
219     /// is left out.
220     title: []const u8 = "",
221     /// A description of the resource, sent as `description`. The field is empty by default, and an
222     /// empty value is left out.
223     description: []const u8 = "",
224     /// The resource's size, sent as the number `size`. The field is null by default, and a null
225     /// size is left out.
226     size: ?u64 = null,
227 };
228 
229 /// A prompt is a list of these blocks, so a caller can mix text with files, links and media in one
230 /// prompt. The union holds one block of prompt content, of one of six kinds.
231 /// `Client.promptDetailedContent` sends a list of blocks in order. `Client.prompt` and
232 /// `Client.promptDetailed` send a single text block. The client writes every kind as given, without
233 /// checking the capability flags in `Initialize`, so the caller checks them.
234 pub const PromptContent = union(enum) {
235     /// Plain text, written as a block of type `text` with the string in `text`.
236     text: []const u8,
237     /// An image, written as a block of type `image`.
238     image: PromptMedia,
239     /// An audio clip, written as a block of type `audio`.
240     audio: PromptMedia,
241     /// A text resource embedded in the prompt, written as a block of type `resource`.
242     resource_text: PromptTextResource,
243     /// A blob resource embedded in the prompt, written as a block of type `resource`.
244     resource_blob: PromptBlobResource,
245     /// A link to a resource, written as a block of type `resource_link`.
246     resource_link: PromptResourceLink,
247 };
248 
249 /// The kind of one progress report (session update), read from the report's `sessionUpdate` name,
250 /// so the caller switches on the kind of each progress report the agent sends. Eleven tags carry
251 /// the eleven names the package test replays, one each, and every other name maps to `other`.
252 pub const UpdateKind = enum {
253     /// The name `user_message_chunk`: a piece of a user message, with its text in `content.text`.
254     user_message_chunk,
255     /// The name `agent_message_chunk`: a piece of the agent's reply, with its text in
256     /// `content.text`. This tag is the only kind whose text the client adds to the reply.
257     agent_message_chunk,
258     /// The name `agent_thought_chunk`: a piece of the agent's thinking, with its text in
259     /// `content.text`.
260     agent_thought_chunk,
261     /// The name `tool_call`: a new tool call, with `toolCallId`, `title`, `kind` and `status`.
262     tool_call,
263     /// The name `tool_call_update`: a change to a tool call, with its `toolCallId` and new
264     /// `status`.
265     tool_call_update,
266     /// The name `plan`: the agent's plan, a list of `entries` each with `content` and `status`.
267     plan,
268     /// The name `available_commands_update`: the commands the agent offers, in `availableCommands`.
269     available_commands_update,
270     /// The name `current_mode_update`: the session's mode changed, with the new id in
271     /// `currentModeId`.
272     current_mode_update,
273     /// The name `config_option_update`: a change to the agent's `configOptions` list.
274     config_option_update,
275     /// The name `session_info_update`: session details such as `title` and `updatedAt`.
276     session_info_update,
277     /// The name `usage_update`: usage figures in `used` and `size`.
278     usage_update,
279     /// Any other name, which stays in `Update.name`.
280     other,
281 };
282 
283 /// One `session/update` notification from the agent, with the two numbers the client gave it, the
284 /// exact line, and fields read from it, received by the observer so the caller records and inspects
285 /// the agent's progress. The client builds one structure per notification, hands it to the
286 /// observer, and frees it when the callback returns. The structure owns every `[]u8` field, and
287 /// `deinit` frees them. A field the notification lacks is empty, null or 0.
288 pub const Update = struct {
289     /// The caller's number for the client that received the notification.
290     transport_epoch: u64,
291     /// The notification's place among all the notifications the client has received, counting
292     /// from 1.
293     update_sequence: u64,
294     /// The exact line the agent sent, trimmed of surrounding whitespace. The client trims spaces,
295     /// tabs, carriage returns and newlines from both ends of the line. This slice is borrowed from
296     /// the reader's buffer and remains valid only until the observer's callback returns. The
297     /// `deinit` function leaves it alone.
298     envelope: []const u8,
299     /// The kind read from `sessionUpdate`.
300     kind: UpdateKind,
301     /// The `sessionUpdate` string as the agent sent it. The update owns this field.
302     name: []u8,
303     /// The notification's `params.sessionId`, empty when absent. The update owns this field.
304     session_id: []u8,
305     /// A summary text for the notification. For an `agent_message_chunk`, this field carries the
306     /// chunk's text, and the client adds it to the reply. The client picks the first of these the
307     /// update carries: the content text, the title, the mode id, the first plan entry's content,
308     /// the first command name, the usage as "used/size", and else the update's name. The update
309     /// owns this field.
310     text: []u8,
311     /// A minified JSON copy of the `params.update` object. The update owns this field.
312     raw: []u8,
313     /// The update's `toolCallId`, empty when absent. The update owns this field.
314     tool_call_id: []u8 = &.{},
315     /// The update's `status`, empty when absent. The update owns this field.
316     status: []u8 = &.{},
317     /// The update's `kind`, the tool call's kind, empty when absent. The update owns this field.
318     tool_kind: []u8 = &.{},
319     /// The update's `currentModeId`, empty when absent. The update owns this field.
320     mode_id: []u8 = &.{},
321     /// The update's integer `used`, or null when absent.
322     usage_used: ?i64 = null,
323     /// The update's integer `size`, or null when absent.
324     usage_size: ?i64 = null,
325     /// The number of entries in the update's `entries` list, 0 when absent.
326     plan_entries: usize = 0,
327     /// The number of entries in the update's `availableCommands` list, 0 when absent.
328     available_commands: usize = 0,
329     /// The number of entries in the update's `configOptions` list, 0 when absent.
330     config_options: usize = 0,
331     /// The update's `title`, empty when absent. The update owns this field.
332     title: []u8 = &.{},
333     /// The update's `updatedAt`, empty when absent. The update owns this field.
334     updated_at: []u8 = &.{},
335 
336     /// Frees every owned string with the given allocator and leaves the value undefined, so the
337     /// caller frees an update built with `fromSessionNotification`. The call leaves the borrowed
338     /// line alone.
339     pub fn deinit(self: *Update, allocator: Allocator) void {
340         if (self.name.len != 0) allocator.free(self.name);
341         if (self.session_id.len != 0) allocator.free(self.session_id);
342         if (self.text.len != 0) allocator.free(self.text);
343         if (self.raw.len != 0) allocator.free(self.raw);
344         if (self.tool_call_id.len != 0) allocator.free(self.tool_call_id);
345         if (self.status.len != 0) allocator.free(self.status);
346         if (self.tool_kind.len != 0) allocator.free(self.tool_kind);
347         if (self.mode_id.len != 0) allocator.free(self.mode_id);
348         if (self.title.len != 0) allocator.free(self.title);
349         if (self.updated_at.len != 0) allocator.free(self.updated_at);
350         self.* = undefined;
351     }
352 
353     /// Reads `params.update` from a parsed notification object, so the caller turns one parsed
354     /// `session/update` notification into an `Update`, as the client does for each one it reads.
355     /// The call returns null when `params`, `params.update` or its `sessionUpdate` string is
356     /// missing, and the client then fails its call with `error.AgentProtocolError`. The function
357     /// stores the given epoch, sequence and line, and keeps the line by reference. The call copies
358     /// every string it keeps with the allocator, and the caller frees them with `deinit`. The
359     /// function fails with `error.OutOfMemory` and leaves nothing allocated when a copy fails. The
360     /// call reads the object as given and does not look at `method`, because the client checks the
361     /// method name before it calls this.
362     pub fn fromSessionNotification(
363         allocator: Allocator,
364         object: std.json.ObjectMap,
365         transport_epoch: u64,
366         update_sequence: u64,
367         envelope: []const u8,
368     ) !?Update {
369         const params = json.objectObject(object, "params") orelse return null;
370         const update = json.objectObject(params, "update") orelse return null;
371         const name = json.objectString(update, "sessionUpdate") orelse return null;
372         const session_id = json.objectString(params, "sessionId") orelse "";
373         const owned_name = try allocator.dupe(u8, name);
374         errdefer allocator.free(owned_name);
375         const owned_session = try allocator.dupe(u8, session_id);
376         errdefer allocator.free(owned_session);
377         const text = try summaryTextAlloc(allocator, update, name);
378         errdefer if (text.len != 0) allocator.free(text);
379         const raw = try rawJsonAlloc(allocator, update);
380         errdefer if (raw.len != 0) allocator.free(raw);
381         const tool_call_id = try optionalStringAlloc(allocator, update, "toolCallId");
382         errdefer if (tool_call_id.len != 0) allocator.free(tool_call_id);
383         const status = try optionalStringAlloc(allocator, update, "status");
384         errdefer if (status.len != 0) allocator.free(status);
385         const tool_kind = try optionalStringAlloc(allocator, update, "kind");
386         errdefer if (tool_kind.len != 0) allocator.free(tool_kind);
387         const mode_id = try optionalStringAlloc(allocator, update, "currentModeId");
388         errdefer if (mode_id.len != 0) allocator.free(mode_id);
389         const title = try optionalStringAlloc(allocator, update, "title");
390         errdefer if (title.len != 0) allocator.free(title);
391         const updated_at = try optionalStringAlloc(allocator, update, "updatedAt");
392         errdefer if (updated_at.len != 0) allocator.free(updated_at);
393         return .{
394             .transport_epoch = transport_epoch,
395             .update_sequence = update_sequence,
396             .envelope = envelope,
397             .kind = kindFromName(name),
398             .name = owned_name,
399             .session_id = owned_session,
400             .text = text,
401             .raw = raw,
402             .tool_call_id = tool_call_id,
403             .status = status,
404             .tool_kind = tool_kind,
405             .mode_id = mode_id,
406             .usage_used = json.objectInteger(update, "used"),
407             .usage_size = json.objectInteger(update, "size"),
408             .plan_entries = arrayLength(update, "entries"),
409             .available_commands = arrayLength(update, "availableCommands"),
410             .config_options = arrayLength(update, "configOptions"),
411             .title = title,
412             .updated_at = updated_at,
413         };
414     }
415 
416     /// Returns true for an `agent_message_chunk`, the only kind the client adds to a reply, so the
417     /// caller tells whether an update carries reply text.
418     pub fn assistantMessage(self: Update) bool {
419         return self.kind == .agent_message_chunk;
420     }
421 };
422 
423 /// The reply limit a prompt reached.
424 pub const OverflowKind = enum {
425     /// The reply text would have passed `TransferLimits.response_bytes`.
426     response_bytes,
427     /// The number of chunks would have passed `TransferLimits.response_segment_count`.
428     response_segments,
429 };
430 
431 /// Records what a reply kept when one more chunk would have passed a limit, and which update the
432 /// client left out, so the caller tells a cut reply from a whole one and sees where the reply
433 /// stopped. The client sets this record at most once per prompt, and the reply stops taking text
434 /// after it. `PromptResult.overflow` carries the record. The fields name the limit reached, the
435 /// reply bytes and chunks kept before the limit, and the update sequence of the first chunk left
436 /// out.
437 pub const Overflow = struct {
438     /// The limit the reply reached.
439     kind: OverflowKind,
440     /// The reply bytes kept before the limit.
441     admitted_bytes: usize,
442     /// The chunks kept before the limit.
443     admitted_segments: usize,
444     /// The update sequence of the first chunk left out of the reply. The observer still received
445     /// that update.
446     withheld_update_sequence: u64,
447 };
448 
449 /// One `session/request_permission` request from the agent: the tool call it names, the decision,
450 /// and the exact line. The caller records what the agent asked for and what was answered because
451 /// the observer receives each permission request and its decision as one of these records. The
452 /// client builds one twice per request: a preview for `permissionFn` with outcome `pending`, empty
453 /// option fields, and an empty line, then the full record for `permissionCommitFn`. The record owns
454 /// every `[]u8` field, and `deinit` frees them.
455 pub const PermissionRequest = struct {
456     /// The exact line the agent sent, trimmed of surrounding whitespace, and empty in the preview.
457     /// The line is borrowed from the reader's buffer and remains valid only until
458     /// `permissionCommitFn` returns.
459     envelope: []const u8,
460     /// The request's `params.sessionId`, empty when absent.
461     session_id: []u8,
462     /// The tool call's `toolCallId`, empty when absent.
463     tool_call_id: []u8,
464     /// The tool call's `title`, empty when absent. The permission policy matches its title prefixes
465     /// against it.
466     title: []u8,
467     /// The tool call's `kind`, empty when the tool call omits a kind.
468     tool_kind: []u8,
469     /// The tool call's `status`, empty when absent.
470     status: []u8,
471     /// The id of the chosen option, empty when the outcome is `cancelled` and in the preview.
472     option_id: []u8,
473     /// The kind of the chosen option, such as `allow_once` or `reject_once`, empty when an option
474     /// remains unchosen.
475     option_kind: []u8,
476     /// The decision: `selected`, `cancelled`, or `pending` in the preview.
477     outcome: []u8,
478     /// The number of options the agent offered. The field defaults to 0.
479     options: usize = 0,
480     /// A minified JSON copy of the request's `params` object.
481     raw: []u8,
482 
483     /// Frees every owned string with the given allocator and leaves the value undefined for a
484     /// record built with `fromClientRequest`. The call leaves the borrowed line alone.
485     pub fn deinit(self: *PermissionRequest, allocator: Allocator) void {
486         if (self.session_id.len != 0) allocator.free(self.session_id);
487         if (self.tool_call_id.len != 0) allocator.free(self.tool_call_id);
488         if (self.title.len != 0) allocator.free(self.title);
489         if (self.tool_kind.len != 0) allocator.free(self.tool_kind);
490         if (self.status.len != 0) allocator.free(self.status);
491         if (self.option_id.len != 0) allocator.free(self.option_id);
492         if (self.option_kind.len != 0) allocator.free(self.option_kind);
493         if (self.outcome.len != 0) allocator.free(self.outcome);
494         if (self.raw.len != 0) allocator.free(self.raw);
495         self.* = undefined;
496     }
497 
498     /// Turns one parsed permission request and its decision into a `PermissionRequest` by reading
499     /// `params` and `params.toolCall` from a parsed request object and adding the given decision
500     /// and line, as the client does for the preview and the record. The call fails with
501     /// `error.AgentProtocolError` when `params` or `params.toolCall` is missing. The function
502     /// copies every string it keeps with the allocator, keeps the line by reference, and the caller
503     /// frees the rest with `deinit`. The call fails with `error.OutOfMemory` and leaves nothing
504     /// allocated when a copy fails.
505     pub fn fromClientRequest(
506         allocator: Allocator,
507         object: std.json.ObjectMap,
508         envelope: []const u8,
509         outcome: []const u8,
510         option_id: []const u8,
511         option_kind: []const u8,
512     ) !PermissionRequest {
513         const params = json.objectObject(object, "params") orelse return error.AgentProtocolError;
514         const tool_call = json.objectObject(params, "toolCall") orelse return error.AgentProtocolError;
515         const session_id = try optionalStringAlloc(allocator, params, "sessionId");
516         errdefer if (session_id.len != 0) allocator.free(session_id);
517         const tool_call_id = try optionalStringAlloc(allocator, tool_call, "toolCallId");
518         errdefer if (tool_call_id.len != 0) allocator.free(tool_call_id);
519         const title = try optionalStringAlloc(allocator, tool_call, "title");
520         errdefer if (title.len != 0) allocator.free(title);
521         const tool_kind = try optionalStringAlloc(allocator, tool_call, "kind");
522         errdefer if (tool_kind.len != 0) allocator.free(tool_kind);
523         const status = try optionalStringAlloc(allocator, tool_call, "status");
524         errdefer if (status.len != 0) allocator.free(status);
525         const owned_option_id = try allocator.dupe(u8, option_id);
526         errdefer allocator.free(owned_option_id);
527         const owned_option_kind = try allocator.dupe(u8, option_kind);
528         errdefer allocator.free(owned_option_kind);
529         const owned_outcome = try allocator.dupe(u8, outcome);
530         errdefer allocator.free(owned_outcome);
531         const raw = try rawJsonAlloc(allocator, params);
532         errdefer if (raw.len != 0) allocator.free(raw);
533         return .{
534             .envelope = envelope,
535             .session_id = session_id,
536             .tool_call_id = tool_call_id,
537             .title = title,
538             .tool_kind = tool_kind,
539             .status = status,
540             .option_id = owned_option_id,
541             .option_kind = owned_option_kind,
542             .outcome = owned_outcome,
543             .options = arrayLength(params, "options"),
544             .raw = raw,
545         };
546     }
547 };
548 
549 /// The caller gets the reply with the reason the agent stopped and whether the reply was cut short
550 /// because `Client.promptDetailed` and `Client.promptDetailedContent` return one of these results.
551 /// The result holds the reply text, the stop reason, and an optional overflow record. The result
552 /// owns the reply and the stop reason, and `deinit` frees them.
553 pub const PromptResult = struct {
554     /// The reply: the text of the agent's message chunks, joined in arrival order, within the reply
555     /// limits. The result owns this slice.
556     response: []u8,
557     /// The `stopReason` string from the prompt's response, such as `end_turn` or `cancelled`. The
558     /// result owns this slice.
559     stop_reason: []u8,
560     /// The overflow record set when the reply reached a limit, and null otherwise.
561     overflow: ?Overflow = null,
562 
563     /// Frees the reply and the stop reason with the given allocator and leaves the value undefined
564     /// once the caller is done with a prompt result. The call takes the client's allocator.
565     pub fn deinit(self: *PromptResult, allocator: Allocator) void {
566         if (self.response.len != 0) allocator.free(self.response);
567         if (self.stop_reason.len != 0) allocator.free(self.stop_reason);
568         self.* = undefined;
569     }
570 };
571 
572 fn responseResult(value: std.json.Value) !std.json.ObjectMap {
573     const object = json.getObject(value) orelse return error.AgentProtocolError;
574     if (object.get("error") != null) return error.AgentProtocolError;
575     const result = object.get("result") orelse return error.AgentProtocolError;
576     return json.getObject(result) orelse return error.AgentProtocolError;
577 }
578 
579 fn arrayLength(object: std.json.ObjectMap, key: []const u8) usize {
580     const value = object.get(key) orelse return 0;
581     return switch (value) {
582         .array => |array| array.items.len,
583         else => 0,
584     };
585 }
586 
587 fn kindFromName(name: []const u8) UpdateKind {
588     if (std.mem.eql(u8, name, "user_message_chunk")) return .user_message_chunk;
589     if (std.mem.eql(u8, name, "agent_message_chunk")) return .agent_message_chunk;
590     if (std.mem.eql(u8, name, "agent_thought_chunk")) return .agent_thought_chunk;
591     if (std.mem.eql(u8, name, "tool_call")) return .tool_call;
592     if (std.mem.eql(u8, name, "tool_call_update")) return .tool_call_update;
593     if (std.mem.eql(u8, name, "plan")) return .plan;
594     if (std.mem.eql(u8, name, "available_commands_update")) return .available_commands_update;
595     if (std.mem.eql(u8, name, "current_mode_update")) return .current_mode_update;
596     if (std.mem.eql(u8, name, "config_option_update")) return .config_option_update;
597     if (std.mem.eql(u8, name, "session_info_update")) return .session_info_update;
598     if (std.mem.eql(u8, name, "usage_update")) return .usage_update;
599     return .other;
600 }
601 
602 fn summaryTextAlloc(allocator: Allocator, update: std.json.ObjectMap, fallback: []const u8) ![]u8 {
603     if (contentText(update)) |text| return try allocator.dupe(u8, text);
604     if (json.objectString(update, "title")) |title| return try allocator.dupe(u8, title);
605     if (json.objectString(update, "currentModeId")) |mode| return try allocator.dupe(u8, mode);
606     if (planText(update)) |text| return try allocator.dupe(u8, text);
607     if (commandText(update)) |text| return try allocator.dupe(u8, text);
608     if (try usageTextAlloc(allocator, update)) |text| return text;
609     return try allocator.dupe(u8, fallback);
610 }
611 
612 fn contentText(update: std.json.ObjectMap) ?[]const u8 {
613     const content = json.objectObject(update, "content") orelse return null;
614     return json.objectString(content, "text");
615 }
616 
617 fn planText(update: std.json.ObjectMap) ?[]const u8 {
618     const entries_value = update.get("entries") orelse return null;
619     const entries = switch (entries_value) {
620         .array => |array| array,
621         else => return null,
622     };
623     if (entries.items.len == 0) return null;
624     const first = json.getObject(entries.items[0]) orelse return null;
625     return json.objectString(first, "content");
626 }
627 
628 fn commandText(update: std.json.ObjectMap) ?[]const u8 {
629     const commands_value = update.get("availableCommands") orelse return null;
630     const commands = switch (commands_value) {
631         .array => |array| array,
632         else => return null,
633     };
634     if (commands.items.len == 0) return null;
635     const first = json.getObject(commands.items[0]) orelse return null;
636     return json.objectString(first, "name");
637 }
638 
639 fn usageTextAlloc(allocator: Allocator, update: std.json.ObjectMap) Allocator.Error!?[]u8 {
640     const used = json.objectInteger(update, "used") orelse return null;
641     const size = json.objectInteger(update, "size") orelse return null;
642     return try std.fmt.allocPrint(allocator, "{d}/{d}", .{ used, size });
643 }
644 
645 fn optionalStringAlloc(allocator: Allocator, object: std.json.ObjectMap, key: []const u8) Allocator.Error![]u8 {
646     const value = json.objectString(object, key) orelse return &.{};
647     return try allocator.dupe(u8, value);
648 }
649 
650 fn authMethodIdsAlloc(allocator: Allocator, object: std.json.ObjectMap) ![]u8 {
651     const value = object.get("authMethods") orelse return &.{};
652     const methods = switch (value) {
653         .array => |array| array,
654         else => return &.{},
655     };
656     if (methods.items.len == 0) return &.{};
657     var out: std.Io.Writer.Allocating = .init(allocator);
658     errdefer out.deinit();
659     var writer = pretty_json.Writer.init(&out.writer, .minified);
660     const ids = try writer.array();
661     var written: usize = 0;
662     for (methods.items) |item| {
663         const method = json.getObject(item) orelse continue;
664         const id = json.objectString(method, "id") orelse continue;
665         try ids.element(id);
666         written += 1;
667     }
668     if (written == 0) {
669         out.deinit();
670         return &.{};
671     }
672     try ids.end();
673     return try out.toOwnedSlice();
674 }
675 
676 fn rawJsonAlloc(allocator: Allocator, update: std.json.ObjectMap) ![]u8 {
677     var out: std.Io.Writer.Allocating = .init(allocator);
678     errdefer out.deinit();
679     try pretty_json.writeMinified(&out.writer, std.json.Value{ .object = update });
680     return try out.toOwnedSlice();
681 }
682 
683 test "protocol parses session update metadata" {
684     const testing = std.testing;
685     const envelope =
686         \\{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc1","title":"read file","kind":"read","status":"completed","content":[]}}}
687     ;
688     var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, envelope, .{});
689     defer parsed.deinit();
690     const object = json.getObject(parsed.value).?;
691     var update = (try Update.fromSessionNotification(testing.allocator, object, 4, 7, envelope)).?;
692     defer update.deinit(testing.allocator);
693     try testing.expectEqual(UpdateKind.tool_call_update, update.kind);
694     try testing.expectEqualStrings("tc1", update.tool_call_id);
695     try testing.expectEqualStrings("completed", update.status);
696     try testing.expectEqualStrings("read", update.tool_kind);
697     try testing.expectEqual(@as(u64, 4), update.transport_epoch);
698     try testing.expectEqual(@as(u64, 7), update.update_sequence);
699     try testing.expectEqualStrings(envelope, update.envelope);
700 }
701 
702 test "protocol parses session update summaries" {
703     const testing = std.testing;
704     const envelope =
705         \\{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"plan","entries":[{"content":"inspect","status":"pending"}]}}}
706     ;
707     var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, envelope, .{});
708     defer parsed.deinit();
709     const object = json.getObject(parsed.value).?;
710     var update = (try Update.fromSessionNotification(testing.allocator, object, 1, 1, envelope)).?;
711     defer update.deinit(testing.allocator);
712     try testing.expectEqual(UpdateKind.plan, update.kind);
713     try testing.expectEqualStrings("plan", update.name);
714     try testing.expectEqualStrings("s1", update.session_id);
715     try testing.expectEqualStrings("inspect", update.text);
716     try testing.expect(std.mem.indexOf(u8, update.raw, "\"sessionUpdate\":\"plan\"") != null);
717 }
718 
719 test "protocol parses initialize capabilities" {
720     const testing = std.testing;
721     var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator,
722         \\{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"audio":true,"image":true,"embeddedContext":true}},"authMethods":[{"id":"oauth\"\n"},{"id":"token"}]}}
723     , .{});
724     defer parsed.deinit();
725     var initialize = try Initialize.fromResponse(testing.allocator, parsed.value);
726     defer initialize.deinit(testing.allocator);
727     try testing.expectEqual(@as(i64, 1), initialize.protocol_version);
728     try testing.expect(initialize.load_session);
729     try testing.expect(initialize.prompt_audio);
730     try testing.expect(initialize.prompt_image);
731     try testing.expect(initialize.prompt_embedded_content);
732     try testing.expectEqual(@as(usize, 2), initialize.auth_methods);
733     try testing.expectEqualStrings("[\"oauth\\\"\\n\",\"token\"]", initialize.auth_method_ids);
734     try testing.expect(std.mem.indexOf(u8, initialize.raw, "\"loadSession\":true") != null);
735 }
736 
737 test "protocol parses session modes" {
738     const testing = std.testing;
739     var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator,
740         \\{"jsonrpc":"2.0","id":1,"result":{"sessionId":"s1","modes":{"currentModeId":"plan","availableModes":[{"id":"plan","name":"Plan"},{"id":"act","name":"Act"}]}}}
741     , .{});
742     defer parsed.deinit();
743     var modes = (try Modes.fromResponse(testing.allocator, parsed.value)).?;
744     defer modes.deinit(testing.allocator);
745     try testing.expectEqualStrings("plan", modes.current);
746     try testing.expectEqual(@as(usize, 2), modes.available);
747     try testing.expect(std.mem.indexOf(u8, modes.raw, "\"currentModeId\":\"plan\"") != null);
748 }