From 1261d8c2f5f397f50921b0d5a9cfcbc6d1fd3a0b Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 19 Oct 2026 15:06:27 -0500 Subject: [PATCH] agent: a permission request says what the agent would do The agent's `session/request_permission` showed only the tool call's title beside its options. Now the question reads as one line per kind: the command for execute, the file and its +/- line count for an edit (or Create for a new file, or N files), the path (and line) for read, delete and move, the pattern for search, the URL for fetch, the title otherwise. The step's disclosure holds the detail: the request's diffs and locations, the whole command, or the call's raw input as JSON when nothing else says what it is. A request about a call the chat has not seen becomes a step, so every request has one. The agent's options are unchanged. A request still waiting when the turn is stopped, the agent exits, or it restarts is answered `cancelled` (ACP) and its question closes through the new `chat.unask`, so no buttons are left answering nothing. Part of fizzyedit/plans AGENTS_ANYWHERE_PLAN, step 6. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- build.zig | 7 + src/AcpClient.zig | 173 +++++++++++++-------- src/Commands.zig | 7 + src/Transcript.zig | 30 +++- src/api.zig | 6 +- src/permission.zig | 377 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 539 insertions(+), 68 deletions(-) create mode 100644 src/permission.zig diff --git a/README.md b/README.md index 3497e66..3c53c0d 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,10 @@ environment needs fizzy to pass it on, which comes later (the agents plan names - Starts the agent, opens a session in the project folder, and hands it `fizzy-mcp`. - Streams the agent's messages and thoughts, its plan, and its tool calls as they run. -- Asks you before the agent acts when it requests permission, with the agent's own options. +- Asks you before the agent acts when it requests permission, saying what it would do (the + command it would run, the file it would change and by how much, the path it would read) with + the detail behind the step, and the agent's own options. A request still open when the turn is + stopped, the agent exits or it restarts is answered `cancelled`, and its buttons go. - Reads files as fizzy's editor has them, unsaved edits included, and writes an open file as one undoable edit in the editor, so you watch it land and can undo it; a file that is not open is written to disk and shown in a preview tab. (The text plugin's `text.read` / `text.write`.) @@ -51,6 +54,8 @@ the agent's own session when a chat is reopened. - `src/acp.zig`: the wire. JSON-RPC 2.0, one message per line on the agent's stdio. std-only, tested with `zig build test`. +- `src/permission.zig`: what a permission request shows (one line per tool kind), and the + requests still waiting on an answer. std-only, tested. - `src/Agent.zig`: the agent's process, spawned as fizzy spawns a language server (on macOS through `core.darwin_spawn`, found on the login shell's PATH), with a reader thread queueing lines for the UI thread. diff --git a/build.zig b/build.zig index a0d5599..fc5fb16 100644 --- a/build.zig +++ b/build.zig @@ -27,8 +27,15 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .root_source_file = b.path("src/auth.zig"), }) }); + // What a permission request shows, and the requests still waiting on an answer. + const permission_tests = b.addTest(.{ .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/permission.zig"), + }) }); const test_step = b.step("test", "Run the chat's tests"); test_step.dependOn(&b.addRunArtifact(auth_tests).step); test_step.dependOn(&b.addRunArtifact(tests).step); test_step.dependOn(&b.addRunArtifact(transcript_tests).step); + test_step.dependOn(&b.addRunArtifact(permission_tests).step); } diff --git a/src/AcpClient.zig b/src/AcpClient.zig index 2956743..a2a17bc 100644 --- a/src/AcpClient.zig +++ b/src/AcpClient.zig @@ -15,6 +15,8 @@ const dvui = @import("dvui"); const api = @import("api.zig"); const acp = @import("acp.zig"); const auth = @import("auth.zig"); +const permission = @import("permission.zig"); +const RequestId = permission.RequestId; const Agent = @import("Agent.zig"); const Settings = @import("Settings.zig"); const json = std.json; @@ -115,12 +117,16 @@ const Session = struct { replaying: bool = false, /// This turn's message, made when the agent first says or does something in it. turn: ?api.MessageId = null, - /// The agent's `toolCallId`s and the steps they are in the chat; hidden ones map to null. - tools: std.StringHashMapUnmanaged(?api.ToolId) = .empty, - /// Questions asked for the agent's permission requests, and the request to answer. - requests: std.AutoHashMapUnmanaged(api.QuestionId, RequestId) = .empty, + /// The agent's `toolCallId`s, the steps they are in the chat (hidden ones have none) and + /// their kind, which a later permission request about them may leave out. + tools: std.StringHashMapUnmanaged(Known) = .empty, + /// Questions asked for the agent's permission requests, and the request each answers. + requests: permission.Requests = .{}, fn deinit(self: *Session) void { + // The chat is closed: its questions have nowhere to close, and the agent stops anyway. + self.requests.deinit(self.gpa); + self.requests = .{}; self.disconnect(); if (self.queued) |*q| q.arena.deinit(); if (self.command_line) |c| self.gpa.free(c); @@ -132,10 +138,12 @@ const Session = struct { var it = self.tools.keyIterator(); while (it.next()) |k| self.gpa.free(k.*); self.tools.deinit(self.gpa); - self.requests.deinit(self.gpa); } fn disconnect(self: *Session) void { + // Each request waiting is answered before the agent goes, and its buttons go: they + // would answer nothing. + dropRequests(self, true); if (self.launch) |l| l.abandon(self.gpa); self.launch = null; if (self.agent) |a| a.stop(); @@ -143,9 +151,6 @@ const Session = struct { self.pending.clearRetainingCapacity(); if (self.session_id) |s| self.gpa.free(s); self.session_id = null; - var it = self.requests.valueIterator(); - while (it.next()) |r| r.deinit(self.gpa); - self.requests.clearRetainingCapacity(); if (self.in_flight) |*q| q.arena.deinit(); self.in_flight = null; // A sign-in is for the agent that asked for it. @@ -155,27 +160,13 @@ const Session = struct { } }; -/// An agent's request id, kept to answer it: a number or a string, as the agent wrote it. -const RequestId = union(enum) { - int: i64, - str: []u8, - - fn of(gpa: std.mem.Allocator, v: json.Value) ?RequestId { - return switch (v) { - .integer => |n| .{ .int = n }, - .string => |s| .{ .str = gpa.dupe(u8, s) catch return null }, - else => null, - }; - } - fn value(self: RequestId) json.Value { - return switch (self) { - .int => |n| .{ .integer = n }, - .str => |s| .{ .string = s }, - }; - } - fn deinit(self: RequestId, gpa: std.mem.Allocator) void { - if (self == .str) gpa.free(self.str); - } +/// A tool call the agent started: its step in the chat (none for a hidden one), and its kind. +const Known = struct { + tool: ?api.ToolId, + kind: api.Tool.Kind, + /// Whether its step shows content: a permission request adds the call's detail only to a step + /// with none, rather than replace what is there. + has_content: bool = false, }; // ---- the chat's side ------------------------------------------------------------------------ @@ -354,7 +345,7 @@ fn onAnswer(a: api.Answer) void { s.sign_in_question = null; return signIn(s, s.sign_in.chose(s.methods, a.option)); } - const rid = (s.requests.fetchRemove(a.question) orelse return).value; + const rid = s.requests.take(a.question) orelse return; defer rid.deinit(s.gpa); write(s, acp.writeResult, .{ rid.value(), .{ .outcome = .{ .outcome = "selected", .optionId = a.option } } }); } @@ -581,15 +572,21 @@ fn prompt(s: *Session, blocks: []const Content) void { fn cancel(s: *Session) void { const session = s.session_id orelse return; if (s.state != .prompting) return; - var it = s.requests.valueIterator(); - while (it.next()) |r| { - write(s, acp.writeResult, .{ r.value(), .{ .outcome = .{ .outcome = "cancelled" } } }); - r.deinit(s.gpa); - } - s.requests.clearRetainingCapacity(); + // ACP: a cancelled turn answers each permission request it left waiting `cancelled`. + dropRequests(s, true); write(s, acp.writeNotification, .{ "session/cancel", .{ .sessionId = session } }); } +/// Every permission request still waiting, answered `cancelled` (unless the agent is gone: +/// `answer` false), its question closed in the chat. +fn dropRequests(s: *Session, answer: bool) void { + while (s.requests.pop()) |e| { + if (answer) write(s, acp.writeResult, .{ e.request.value(), .{ .outcome = .{ .outcome = "cancelled" } } }); + e.request.deinit(s.gpa); + _ = call("chat.unask", api.Unask{ .chat = s.chat, .question = e.question }); + } +} + fn request(s: *Session, what: Pending, method: []const u8, params: anytype) void { const id = s.next_id; s.next_id += 1; @@ -624,6 +621,8 @@ fn frameSession(s: *Session) void { var buf: [600]u8 = undefined; const why = std.mem.trim(u8, agent.stderrTail(&buf), " \t\r\n"); if (why.len > 0) note(s, "The agent stopped:\n\n```\n{s}\n```", .{why}) else note(s, "The agent stopped.", .{}); + // Its requests went with it: nothing to answer, only buttons to take away. + dropRequests(s, false); s.disconnect(); setBusy(s, false); if (s.queued) |*q| { @@ -856,7 +855,7 @@ fn update(s: *Session, params: json.Value) void { } else if (std.mem.eql(u8, kind, "agent_thought_chunk")) { content(s, field(u, "content"), .thought); } else if (std.mem.eql(u8, kind, "tool_call")) { - toolCall(s, u); + _ = toolCall(s, u); } else if (std.mem.eql(u8, kind, "tool_call_update")) { toolUpdate(s, u); } else if (std.mem.eql(u8, kind, "config_option_update")) { @@ -984,66 +983,116 @@ fn toolContent(arena: std.mem.Allocator, u: json.Value) ?[]const api.Block { return if (any) out.items else null; } -fn toolCall(s: *Session, u: json.Value) void { - const id = str(field(u, "toolCallId")) orelse return; +/// A tool call's step in the chat, made now; a call the chat has already is updated instead +/// (a permission request can come before its `tool_call`, which then repeats it). +fn toolCall(s: *Session, u: json.Value) ?Known { + const id = str(field(u, "toolCallId")) orelse return null; + if (s.tools.get(id)) |known| { + toolUpdate(s, u); + return known; + } const title = str(field(u, "title")) orelse "A tool"; - const key = s.gpa.dupe(u8, id) catch return; + const kind = toolKind(str(field(u, "kind"))); + const arena = sdk.host().arena(); + const given = toolContent(arena, u) orelse &.{}; + const key = s.gpa.dupe(u8, id) catch return null; if (hidden(title)) { - s.tools.put(s.gpa, key, null) catch s.gpa.free(key); - return; + s.tools.put(s.gpa, key, .{ .tool = null, .kind = kind }) catch s.gpa.free(key); + return null; } - const m = turnMessage(s) orelse return s.gpa.free(key); - const arena = sdk.host().arena(); - const r = call("chat.tool", api.AddTool{ .chat = s.chat, .message = m, .tool = .{ - .kind = toolKind(str(field(u, "kind"))), + const m = turnMessage(s) orelse { + s.gpa.free(key); + return null; + }; + const added: ?api.Added = if (call("chat.tool", api.AddTool{ .chat = s.chat, .message = m, .tool = .{ + .kind = kind, .title = readable(arena, title), .status = toolStatus(str(field(u, "status"))) orelse .pending, - .content = toolContent(arena, u) orelse &.{}, - } }) orelse return s.gpa.free(key); - const added = api.parse(api.Added, arena, r) catch return s.gpa.free(key); - s.tools.put(s.gpa, key, added.tool) catch s.gpa.free(key); + .content = given, + } })) |r| api.parse(api.Added, arena, r) catch null else null; + const known: Known = .{ .tool = (added orelse { + s.gpa.free(key); + return null; + }).tool, .kind = kind, .has_content = given.len > 0 }; + s.tools.put(s.gpa, key, known) catch s.gpa.free(key); + return known; } fn toolUpdate(s: *Session, u: json.Value) void { - const tid = (s.tools.get(str(field(u, "toolCallId")) orelse return) orelse return) orelse return; + const known = s.tools.getPtr(str(field(u, "toolCallId")) orelse return) orelse return; + if (str(field(u, "kind"))) |k| known.kind = toolKind(k); + const tid = known.tool orelse return; const arena = sdk.host().arena(); + const given = toolContent(arena, u); + if (given) |c| known.has_content = c.len > 0; _ = call("chat.update_tool", api.UpdateTool{ .chat = s.chat, .tool = tid, .update = .{ .title = if (str(field(u, "title"))) |t| readable(arena, t) else null, .status = toolStatus(str(field(u, "status"))), - .content = toolContent(arena, u), + .content = given, } }); } -/// `session/request_permission`: buttons on the tool call it is about, the agent's own options. +/// `session/request_permission`: buttons on the tool call it is about, the agent's own options, +/// and one line saying what the call would do (the command, the file and how much of it, the +/// path), with the detail behind the step's disclosure. A call the chat has not seen yet becomes +/// a step now, so the request always has one to show. fn askPermission(s: *Session, id: json.Value, params: json.Value) void { const rid = RequestId.of(s.gpa, id) orelse return; const arena = sdk.host().arena(); - const callv = field(params, "toolCall"); - const tid: ?api.ToolId = if (str(field(callv, "toolCallId"))) |tc| (s.tools.get(tc) orelse null) orelse null else null; + const callv = field(params, "toolCall") orelse .null; + const tcid = str(field(callv, "toolCallId")) orelse ""; + const before: ?*Known = s.tools.getPtr(tcid); + // A call the chat has not seen is made a step now, with what the request says of it. + const known: ?Known = if (before) |k| k.* else toolCall(s, callv); + const kind = if (str(field(callv, "kind"))) |k| toolKind(k) else if (known) |k| k.kind else .other; + const title = readable(arena, str(field(callv, "title")) orelse "use a tool"); + // The step's disclosure, in one update: the content the request gives (an empty list never + // wipes what the step shows), and the whole command or the call's input where the step would + // otherwise say nothing about it. + if (known) |k| if (k.tool) |tid| { + const given = toolContent(arena, callv) orelse &.{}; + const extra = if (given.len > 0 or !k.has_content) permission.detail(arena, callv, kind) else null; + var blocks: std.ArrayListUnmanaged(api.Block) = .empty; + // A step made just now has the given content already. + if (before != null) blocks.appendSlice(arena, given) catch {}; + if (extra) |e| blocks.append(arena, e) catch {}; + if (blocks.items.len > 0) { + if (before == null) blocks.insertSlice(arena, 0, given) catch {}; + _ = call("chat.update_tool", api.UpdateTool{ .chat = s.chat, .tool = tid, .update = .{ + .title = if (before != null and str(field(callv, "title")) != null) title else null, + .content = blocks.items, + } }); + if (s.tools.getPtr(tcid)) |p| p.has_content = true; + } + }; var options: std.ArrayListUnmanaged(api.Question.Option) = .empty; if (field(params, "options")) |opts| if (opts == .array) for (opts.array.items) |o| { const oid = str(field(o, "optionId")) orelse continue; - const kind = str(field(o, "kind")) orelse ""; + const okind = str(field(o, "kind")) orelse ""; options.append(arena, .{ .id = oid, .label = str(field(o, "name")) orelse oid, - .kind = if (std.mem.eql(u8, kind, "allow_once")) .allow else if (std.mem.eql(u8, kind, "allow_always")) .allow_always else if (std.mem.eql(u8, kind, "reject_once")) .deny else if (std.mem.eql(u8, kind, "reject_always")) .deny_always else .neutral, + .kind = if (std.mem.eql(u8, okind, "allow_once")) .allow else if (std.mem.eql(u8, okind, "allow_always")) .allow_always else if (std.mem.eql(u8, okind, "reject_once")) .deny else if (std.mem.eql(u8, okind, "reject_always")) .deny_always else .neutral, }) catch {}; }; - const title = str(field(callv, "title")) orelse "use a tool"; + // Paths are written relative to a project folder only: relative to home they would mislead. + const project = s.folder orelse sdk.host().folder() orelse ""; + const line = permission.summary(arena, callv, kind, title, project); + const tool: ?api.ToolId = if (known) |k| k.tool else null; const m = turnMessage(s) orelse return rid.deinit(s.gpa); const r = call("chat.ask", api.Ask{ .chat = s.chat, .message = m, .question = .{ - // On its tool's line the call says what; on its own the question does. - .text = if (tid != null) "" else std.fmt.allocPrint(arena, "The agent asks to {s}.", .{readable(arena, title)}) catch "", + // On its step's line a summary that only repeats the title says nothing more; on its + // own the question says it is one. + .text = if (!std.mem.eql(u8, line, title)) line else if (tool != null) "" else std.fmt.allocPrint(arena, "The agent asks to {s}.", .{title}) catch title, .options = options.items, - .tool = tid, + .tool = tool, }, }) orelse return rid.deinit(s.gpa); const asked = api.parse(api.Asked, arena, r) catch return rid.deinit(s.gpa); - s.requests.put(s.gpa, asked.question, rid) catch rid.deinit(s.gpa); + s.requests.put(s.gpa, asked.question, rid); } // ---- files ---------------------------------------------------------------------------------- diff --git a/src/Commands.zig b/src/Commands.zig index fc54933..8d14550 100644 --- a/src/Commands.zig +++ b/src/Commands.zig @@ -128,6 +128,7 @@ pub fn register(host: *sdk.Host, owner: *sdk.Plugin) !void { .{ "update_tool", api.UpdateTool, cmdUpdateTool }, .{ "set_plan", api.SetPlan, cmdSetPlan }, .{ "ask", api.Ask, cmdAsk }, + .{ "unask", api.Unask, cmdUnask }, .{ "set_busy", api.SetBusy, cmdSetBusy }, .{ "set_title", api.SetTitle, cmdSetTitle }, .{ "set_resume", api.SetResume, cmdSetResume }, @@ -298,6 +299,12 @@ fn cmdAsk(a: api.Ask, call: *sdk.Command.Call) !void { try call.returns(api.Asked{ .question = id }); } +fn cmdUnask(a: api.Unask, call: *sdk.Command.Call) !void { + const c = try find(a.chat, call); + c.transcript.cancelQuestion(a.question); + changed(c); +} + fn cmdSetBusy(a: api.SetBusy, call: *sdk.Command.Call) !void { const c = try find(a.chat, call); c.busy = a.busy; diff --git a/src/Transcript.zig b/src/Transcript.zig index b5fcb6e..ed01015 100644 --- a/src/Transcript.zig +++ b/src/Transcript.zig @@ -301,10 +301,16 @@ pub fn answer(self: *Transcript, qid: chat.QuestionId, option: u32) ?[]const u8 /// Close every open question: the turn ended without them. pub fn cancelQuestions(self: *Transcript) void { - for (self.questions.items) |*q| if (q.answer == .open) { - q.answer = .cancelled; - self.changed(if (q.tool) |t| self.tools.items[t].message else q.message); - }; + for (0..self.questions.items.len) |i| self.cancelQuestion(@intCast(i)); +} + +/// Close question `qid` if it is still open: what it asked about went before an answer. +pub fn cancelQuestion(self: *Transcript, qid: chat.QuestionId) void { + if (qid >= self.questions.items.len) return; + const q = &self.questions.items[qid]; + if (q.answer != .open) return; + q.answer = .cancelled; + self.changed(if (q.tool) |t| self.tools.items[t].message else q.message); } fn listOf(gpa: std.mem.Allocator, text: []const u8) !std.ArrayListUnmanaged(u8) { @@ -637,6 +643,22 @@ test "a transcript saves and loads back as it was" { try std.testing.expectEqualStrings("only", parts[3].block.plan[0].text); } +test "a question closed before its answer loses its buttons, an answered one keeps its answer" { + var t: Transcript = .{ .gpa = std.testing.allocator }; + defer t.deinit(); + const m = try t.post(.other, &.{}); + const tid = try t.tool(m, .{ .kind = .execute, .title = "Run" }); + const answered = try t.ask(m, .{ .text = "", .tool = tid, .options = &.{.{ .id = "y", .label = "Allow", .kind = .allow }} }); + const open = try t.ask(m, .{ .text = "Run it?", .options = &.{.{ .id = "y", .label = "Allow", .kind = .allow }} }); + _ = t.answer(answered, 0); + t.cancelQuestion(answered); + t.cancelQuestion(open); + t.cancelQuestion(99); + try std.testing.expectEqual(Question.Answer{ .chosen = 0 }, t.questions.items[answered].answer); + try std.testing.expectEqual(Question.Answer.cancelled, t.questions.items[open].answer); + try std.testing.expect(t.answer(open, 0) == null); +} + test "a first-version transcript reads as messages" { const gpa = std.testing.allocator; var io_impl: std.Io.Threaded = .init_single_threaded; diff --git a/src/api.zig b/src/api.zig index 236ef02..4dbe869 100644 --- a/src/api.zig +++ b/src/api.zig @@ -9,7 +9,8 @@ //! `{ message }`, `chat.append_text` `{ chat, message, kind, text }`, `chat.append` //! `{ chat, message, block }`, `chat.tool` `{ chat, message, tool }` → `{ tool }`, //! `chat.update_tool` `{ chat, tool, update }`, `chat.set_plan` `{ chat, message, entries }`, -//! `chat.ask` `{ chat, message, question }` → `{ question }`, `chat.set_busy` `{ chat, busy }`, +//! `chat.ask` `{ chat, message, question }` → `{ question }`, `chat.unask` `{ chat, question }`, +//! `chat.set_busy` `{ chat, busy }`, //! `chat.set_title` `{ chat, title }`, `chat.set_resume` `{ chat, state }`, `chat.focus` //! `{ chat }`, `chat.set_settings` `{ chat, settings }`. //! @@ -147,6 +148,9 @@ pub const UpdateTool = struct { chat: ChatId, tool: ToolId, update: ToolUpdate } pub const SetPlan = struct { chat: ChatId, message: MessageId, entries: []const PlanEntry }; pub const Ask = struct { chat: ChatId, message: MessageId, question: Question }; pub const Asked = struct { question: QuestionId }; +/// A question no longer open (its request went with the agent, or was cancelled): its buttons go, +/// and it reads as not answered. +pub const Unask = struct { chat: ChatId, question: QuestionId }; pub const SetBusy = struct { chat: ChatId, busy: bool }; pub const SetTitle = struct { chat: ChatId, title: []const u8 }; pub const SetResume = struct { chat: ChatId, state: []const u8 }; diff --git a/src/permission.zig b/src/permission.zig new file mode 100644 index 0000000..c0a048d --- /dev/null +++ b/src/permission.zig @@ -0,0 +1,377 @@ +//! An agent's `session/request_permission`, made something a person can judge: one line saying +//! what the agent wants to do (the command it would run, the file it would change and by how +//! much, the path it would read), with the call's detail behind the tool's disclosure. And the +//! requests still waiting for an answer, so that each gets one: the person's choice, or +//! `cancelled` when the turn, the agent or the session ends first (ACP: protocol/tool-calls, +//! "Requesting Permission"). std-only, tested with `zig build test`. +const std = @import("std"); +const api = @import("api.zig"); +const json = std.json; + +/// What the question says, on one line, as plain text: "Run zig build test", "Edit src/a.zig: +3 +/// −1". Each piece the agent wrote is cut to one short line, so a path or URL cannot add lines +/// of its own beside the buttons. `call` is +/// the request's `toolCall` (a `ToolCallUpdate`, so any field may be missing: `kind` falls back +/// to what the call was when it started), `title` the call's title as the chat shows it, and +/// paths inside `root` are written relative to it. +pub fn summary(arena: std.mem.Allocator, call: json.Value, kind: api.Tool.Kind, title: []const u8, root: []const u8) []const u8 { + return summaryOf(arena, call, kind, title, root) catch title; +} + +fn summaryOf(arena: std.mem.Allocator, call: json.Value, kind: api.Tool.Kind, title: []const u8, root: []const u8) ![]const u8 { + const input = field(call, "rawInput"); + switch (kind) { + .execute => if (command(arena, input)) |cmd| return std.fmt.allocPrint(arena, "Run {s}", .{try oneLine(arena, cmd)}), + .edit => { + var files: usize = 0; + var added: usize = 0; + var removed: usize = 0; + var path: []const u8 = ""; + var created = false; + if (field(call, "content")) |items| if (items == .array) for (items.array.items) |item| { + if (!std.mem.eql(u8, str(field(item, "type")) orelse "", "diff")) continue; + const old = str(field(item, "oldText")); + const n = counts(old orelse "", str(field(item, "newText")) orelse ""); + added += n.added; + removed += n.removed; + files += 1; + path = str(field(item, "path")) orelse path; + created = old == null; + }; + if (files == 1) return std.fmt.allocPrint(arena, "{s} {s}: +{d} \u{2212}{d}", .{ if (created) "Create" else "Edit", try oneLine(arena, relative(path, root)), added, removed }); + if (files > 1) return std.fmt.allocPrint(arena, "Edit {d} files: +{d} \u{2212}{d}", .{ files, added, removed }); + if (paths(arena, call, input, root)) |p| return std.fmt.allocPrint(arena, "Edit {s}", .{p}); + }, + .read => if (paths(arena, call, input, root)) |p| return std.fmt.allocPrint(arena, "Read {s}", .{p}), + .delete => if (paths(arena, call, input, root)) |p| return std.fmt.allocPrint(arena, "Delete {s}", .{p}), + .move => { + const locs = field(call, "locations"); + if (locs != null and locs.? == .array and locs.?.array.items.len == 2) { + const from = str(field(locs.?.array.items[0], "path")) orelse ""; + const to = str(field(locs.?.array.items[1], "path")) orelse ""; + return std.fmt.allocPrint(arena, "Move {s} to {s}", .{ try oneLine(arena, relative(from, root)), try oneLine(arena, relative(to, root)) }); + } + if (paths(arena, call, input, root)) |p| return std.fmt.allocPrint(arena, "Move {s}", .{p}); + }, + .search => if (firstString(input, &.{ "pattern", "query" })) |q| { + const where = paths(arena, call, input, root); + return if (where) |w| + std.fmt.allocPrint(arena, "Search for {s} in {s}", .{ try oneLine(arena, q), w }) + else + std.fmt.allocPrint(arena, "Search for {s}", .{try oneLine(arena, q)}); + }, + .fetch => { + if (firstString(input, &.{"url"})) |u| return std.fmt.allocPrint(arena, "Fetch {s}", .{try oneLine(arena, u)}); + if (firstString(input, &.{"query"})) |q| return std.fmt.allocPrint(arena, "Search the web for {s}", .{try oneLine(arena, q)}); + }, + .think, .other => {}, + } + return title; +} + +/// What the tool's disclosure adds to the call's own content and locations: the whole command, +/// for a call that runs one, or the call's input as the agent wrote it, for a call that says +/// nothing else about itself. Null when the content says it already. +pub fn detail(arena: std.mem.Allocator, call: json.Value, kind: api.Tool.Kind) ?api.Block { + const input = field(call, "rawInput"); + if (kind == .execute) if (command(arena, input)) |cmd| return .{ .code = .{ .language = "sh", .text = cmd } }; + if (nonEmpty(field(call, "content")) or nonEmpty(field(call, "locations"))) return null; + const v = input orelse return null; + if (v == .null) return null; + var out: std.Io.Writer.Allocating = .init(arena); + json.Stringify.value(v, .{ .whitespace = .indent_2 }, &out.writer) catch return null; + var text = out.written(); + if (text.len > 4096) { + var end: usize = 4096; + while (end > 0 and (text[end] & 0xc0) == 0x80) end -= 1; + text = std.fmt.allocPrint(arena, "{s}\n\u{2026}", .{text[0..end]}) catch text[0..end]; + } + return .{ .code = .{ .language = "json", .text = text } }; +} + +/// Lines a diff adds and removes, past what both ends share (as the chat draws it). +pub fn counts(old_text: []const u8, new_text: []const u8) struct { added: usize, removed: usize } { + // A file's last newline ends its last line rather than starting another. + const old = if (std.mem.endsWith(u8, old_text, "\n")) old_text[0 .. old_text.len - 1] else old_text; + const new = if (std.mem.endsWith(u8, new_text, "\n")) new_text[0 .. new_text.len - 1] else new_text; + if (new_text.len == 0) return .{ .added = 0, .removed = if (old_text.len == 0) 0 else std.mem.count(u8, old, "\n") + 1 }; + const a = if (old.len == 0) 0 else std.mem.count(u8, old, "\n") + 1; + const b = std.mem.count(u8, new, "\n") + 1; + var ia: std.mem.SplitIterator(u8, .scalar) = std.mem.splitScalar(u8, old, '\n'); + var ib: std.mem.SplitIterator(u8, .scalar) = std.mem.splitScalar(u8, new, '\n'); + var pre: usize = 0; + if (a > 0) while (pre < a and pre < b) : (pre += 1) if (!std.mem.eql(u8, ia.next().?, ib.next().?)) break; + var ra: std.mem.SplitBackwardsIterator(u8, .scalar) = std.mem.splitBackwardsScalar(u8, old, '\n'); + var rb: std.mem.SplitBackwardsIterator(u8, .scalar) = std.mem.splitBackwardsScalar(u8, new, '\n'); + var suf: usize = 0; + if (a > 0) while (suf < a - pre and suf < b - pre) : (suf += 1) if (!std.mem.eql(u8, ra.next().?, rb.next().?)) break; + return .{ .added = b - pre - suf, .removed = a - pre - suf }; +} + +/// The command a call would run: `rawInput.command` (a string, or argv), else `rawInput.cmd`. +fn command(arena: std.mem.Allocator, input: ?json.Value) ?[]const u8 { + for ([_][]const u8{ "command", "cmd" }) |name| { + const v = field(input, name) orelse continue; + switch (v) { + .string => |s| if (s.len > 0) return s, + .array => |list| { + var out: std.ArrayListUnmanaged(u8) = .empty; + for (list.items, 0..) |item, i| { + if (item != .string) continue; + if (i > 0) out.append(arena, ' ') catch return null; + out.appendSlice(arena, item.string) catch return null; + } + if (out.items.len > 0) return out.items; + }, + else => {}, + } + } + return null; +} + +/// `text` as one short line: its first line, cut at 100 bytes, saying when there is more. +fn oneLine(arena: std.mem.Allocator, text: []const u8) ![]const u8 { + const trimmed = std.mem.trim(u8, text, " \t\r\n"); + const nl = std.mem.indexOfScalar(u8, trimmed, '\n'); + var first = trimmed[0 .. nl orelse trimmed.len]; + var cut = nl != null; + if (first.len > 100) { + var end: usize = 100; + while (end > 0 and (first[end] & 0xc0) == 0x80) end -= 1; + first = first[0..end]; + cut = true; + } + if (!cut) return first; + return std.fmt.allocPrint(arena, "{s}\u{2026}{s}", .{ first, if (nl != null) " (more lines)" else "" }); +} + +/// The paths a call is about: its locations, else the path in its input. "a.zig and 2 more". +fn paths(arena: std.mem.Allocator, call: json.Value, input: ?json.Value, root: []const u8) ?[]const u8 { + if (field(call, "locations")) |locs| if (locs == .array and locs.array.items.len > 0) { + const l = locs.array.items[0]; + const p = oneLine(arena, relative(str(field(l, "path")) orelse return null, root)) catch return null; + const line: ?i64 = if (field(l, "line")) |n| (if (n == .integer and n.integer > 0) n.integer else null) else null; + const more = locs.array.items.len - 1; + const at = if (line) |n| std.fmt.allocPrint(arena, "{s}:{d}", .{ p, n }) catch return null else p; + return if (more == 0) at else std.fmt.allocPrint(arena, "{s} and {d} more", .{ at, more }) catch null; + }; + const p = firstString(input, &.{ "file_path", "path", "notebook_path" }) orelse return null; + return oneLine(arena, relative(p, root)) catch null; +} + +fn relative(path: []const u8, root: []const u8) []const u8 { + const r = std.mem.trimEnd(u8, root, "/"); + if (r.len == 0 or !std.mem.startsWith(u8, path, r)) return path; + if (path.len > r.len and path[r.len] == '/') return path[r.len + 1 ..]; + return path; +} + +fn firstString(v: ?json.Value, names: []const []const u8) ?[]const u8 { + for (names) |n| if (str(field(v, n))) |s| if (s.len > 0) return s; + return null; +} + +fn nonEmpty(v: ?json.Value) bool { + const a = v orelse return false; + return a == .array and a.array.items.len > 0; +} + +fn field(v: ?json.Value, name: []const u8) ?json.Value { + const o = v orelse return null; + if (o != .object) return null; + return o.object.get(name); +} + +fn str(v: ?json.Value) ?[]const u8 { + const s = v orelse return null; + return if (s == .string) s.string else null; +} + +// ---- the requests waiting ------------------------------------------------------------------ + +/// An agent's request id, kept to answer it: a number or a string, as the agent wrote it. +pub const RequestId = union(enum) { + int: i64, + str: []u8, + + pub fn of(gpa: std.mem.Allocator, v: json.Value) ?RequestId { + return switch (v) { + .integer => |n| .{ .int = n }, + .string => |s| .{ .str = gpa.dupe(u8, s) catch return null }, + else => null, + }; + } + pub fn value(self: RequestId) json.Value { + return switch (self) { + .int => |n| .{ .integer = n }, + .str => |s| .{ .string = s }, + }; + } + pub fn deinit(self: RequestId, gpa: std.mem.Allocator) void { + if (self == .str) gpa.free(self.str); + } +}; + +/// The questions asked for permission requests the agent is waiting on, and the request each +/// answers. +pub const Requests = struct { + open: std.AutoArrayHashMapUnmanaged(api.QuestionId, RequestId) = .empty, + + pub const Entry = struct { question: api.QuestionId, request: RequestId }; + + pub fn deinit(self: *Requests, gpa: std.mem.Allocator) void { + for (self.open.values()) |r| r.deinit(gpa); + self.open.deinit(gpa); + } + + /// Wait on `request` for `question`'s answer; on failure the request is freed. + pub fn put(self: *Requests, gpa: std.mem.Allocator, question: api.QuestionId, request: RequestId) void { + self.open.put(gpa, question, request) catch request.deinit(gpa); + } + + /// The request `question` answers, no longer waiting; the caller frees it. + pub fn take(self: *Requests, question: api.QuestionId) ?RequestId { + return (self.open.fetchSwapRemove(question) orelse return null).value; + } + + /// A request still waiting, no longer: to answer `cancelled` and its question to close. + /// Called until null, it empties them all; the caller frees each request. + pub fn pop(self: *Requests) ?Entry { + const kv = self.open.pop() orelse return null; + return .{ .question = kv.key, .request = kv.value }; + } +}; + +// ---- tests ---------------------------------------------------------------------------------- + +fn parsed(arena: std.mem.Allocator, text: []const u8) !json.Value { + return json.parseFromSliceLeaky(json.Value, arena, text, .{}); +} + +test "a command to run reads as the command" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const call = try parsed(arena, + \\{"toolCallId":"t1","title":"Run tests","kind":"execute","rawInput":{"command":"zig build test\n--summary all","description":"Run tests"}} + ); + try std.testing.expectEqualStrings("Run zig build test\u{2026} (more lines)", summary(arena, call, .execute, "Run tests", "/p")); + const d = detail(arena, call, .execute).?; + try std.testing.expectEqualStrings("zig build test\n--summary all", d.code.text); + const argv = try parsed(arena, + \\{"toolCallId":"t2","rawInput":{"command":["rm","-rf","zig-out"]}} + ); + try std.testing.expectEqualStrings("Run rm -rf zig-out", summary(arena, argv, .execute, "Bash", "")); +} + +test "an edit reads as its file and its size" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const one = try parsed(arena, + \\{"toolCallId":"t","kind":"edit","content":[{"type":"diff","path":"/p/src/a.zig","oldText":"a\nb\nc","newText":"a\nB\nB2\nc"}]} + ); + try std.testing.expectEqualStrings("Edit src/a.zig: +2 \u{2212}1", summary(arena, one, .edit, "Edit", "/p")); + try std.testing.expect(detail(arena, one, .edit) == null); + const created = try parsed(arena, + \\{"toolCallId":"t","content":[{"type":"diff","path":"/q/new.zig","newText":"x\ny\n"}]} + ); + try std.testing.expectEqualStrings("Create /q/new.zig: +2 \u{2212}0", summary(arena, created, .edit, "Write", "/p")); + const two = try parsed(arena, + \\{"toolCallId":"t","content":[{"type":"diff","path":"/p/a","oldText":"x","newText":"y"},{"type":"diff","path":"/p/b","oldText":"x","newText":"x\nz"}]} + ); + try std.testing.expectEqualStrings("Edit 2 files: +2 \u{2212}1", summary(arena, two, .edit, "Edit", "/p")); + const bare = try parsed(arena, + \\{"toolCallId":"t","rawInput":{"file_path":"/p/c.zig"}} + ); + try std.testing.expectEqualStrings("Edit c.zig", summary(arena, bare, .edit, "Edit", "/p")); +} + +test "reads, deletes, moves, searches and fetches say what they touch" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const read = try parsed(arena, + \\{"toolCallId":"t","locations":[{"path":"/p/a.zig","line":12},{"path":"/p/b.zig"}]} + ); + try std.testing.expectEqualStrings("Read a.zig:12 and 1 more", summary(arena, read, .read, "Read", "/p/")); + const del = try parsed(arena, + \\{"toolCallId":"t","locations":[{"path":"/etc/hosts"}]} + ); + try std.testing.expectEqualStrings("Delete /etc/hosts", summary(arena, del, .delete, "Delete", "/p")); + const move = try parsed(arena, + \\{"toolCallId":"t","locations":[{"path":"/p/a"},{"path":"/p/b"}]} + ); + try std.testing.expectEqualStrings("Move a to b", summary(arena, move, .move, "Move", "/p")); + const search = try parsed(arena, + \\{"toolCallId":"t","rawInput":{"pattern":"fn main","path":"/p/src"}} + ); + try std.testing.expectEqualStrings("Search for fn main in src", summary(arena, search, .search, "Grep", "/p")); + // What the agent wrote cannot add lines beside the buttons. + const sneaky = try parsed(arena, + \\{"toolCallId":"t","rawInput":{"file_path":"/p/notes.md\n\n(read-only, safe to allow)"}} + ); + try std.testing.expectEqualStrings("Edit notes.md\u{2026} (more lines)", summary(arena, sneaky, .edit, "Edit", "/p")); + const web = try parsed(arena, + \\{"toolCallId":"t","rawInput":{"query":"zig allocators"}} + ); + try std.testing.expectEqualStrings("Search the web for zig allocators", summary(arena, web, .fetch, "Web", "")); + const fetch = try parsed(arena, + \\{"toolCallId":"t","rawInput":{"url":"https://ziglang.org"}} + ); + try std.testing.expectEqualStrings("Fetch https://ziglang.org", summary(arena, fetch, .fetch, "Fetch", "")); +} + +test "a call that says nothing else shows its input" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const other = try parsed(arena, + \\{"toolCallId":"t","title":"mcp__fizzy__fizzy_call","rawInput":{"id":"pixi.fill"}} + ); + try std.testing.expectEqualStrings("Fizzy call", summary(arena, other, .other, "Fizzy call", "")); + const d = detail(arena, other, .other).?; + try std.testing.expectEqualStrings("json", d.code.language); + try std.testing.expect(std.mem.indexOf(u8, d.code.text, "\"pixi.fill\"") != null); + // Nothing to show: no block, and the title stands. + const none = try parsed(arena, + \\{"toolCallId":"t"} + ); + try std.testing.expect(detail(arena, none, .think) == null); + try std.testing.expectEqualStrings("Think", summary(arena, none, .execute, "Think", "")); +} + +test "requests still waiting are all handed back once, to be cancelled" { + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + var r: Requests = .{}; + defer r.deinit(gpa); + r.put(gpa, 3, .{ .int = 7 }); + r.put(gpa, 4, RequestId.of(gpa, .{ .string = "req-8" }).?); + r.put(gpa, 5, .{ .int = 9 }); + const answered = r.take(5).?; + try std.testing.expectEqual(@as(i64, 9), answered.int); + var left: std.ArrayListUnmanaged(Requests.Entry) = .empty; + defer left.deinit(gpa); + while (r.pop()) |e| try left.append(gpa, e); + defer for (left.items) |e| e.request.deinit(gpa); + try std.testing.expectEqual(@as(usize, 2), left.items.len); + var questions = [_]api.QuestionId{ left.items[0].question, left.items[1].question }; + std.mem.sort(api.QuestionId, &questions, {}, std.sort.asc(api.QuestionId)); + try std.testing.expectEqual([_]api.QuestionId{ 3, 4 }, questions); + try std.testing.expect(r.take(3) == null); + try std.testing.expect(r.pop() == null); +} + +test "a diff's size skips what both ends share" { + const n = counts("a\nb\nc", "a\nx\nc"); + try std.testing.expectEqual(@as(usize, 1), n.added); + try std.testing.expectEqual(@as(usize, 1), n.removed); + const fresh = counts("", "one\ntwo\n"); + try std.testing.expectEqual(@as(usize, 2), fresh.added); + try std.testing.expectEqual(@as(usize, 0), fresh.removed); + const emptied = counts("one\ntwo\n", ""); + try std.testing.expectEqual(@as(usize, 0), emptied.added); + try std.testing.expectEqual(@as(usize, 2), emptied.removed); +}