From 9d7a55805fc7024f36f861d58957f6e5bc4e2a58 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Mon, 9 Mar 2026 22:36:58 +0700 Subject: [PATCH] =?UTF-8?q?feat(tri-api):=20Direct=20Anthropic=20API=20age?= =?UTF-8?q?nt=20=E2=80=94=20no=20claude=20CLI=20dependency=20(Issue=20#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained agentic loop in src/tri-api/ (624 LOC, 3 files): - tool_executor.zig: 4 tools (read_file, write_file, bash, grep) via std - tool_protocol.zig: Anthropic Messages API JSON build/parse - main.zig: POST to api.anthropic.com/v1/messages, tool_use loop Zero cross-directory imports. Talks to Claude API directly from Zig. Closes #60 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- build.zig | 18 +++ src/tri-api/main.zig | 203 ++++++++++++++++++++++++ src/tri-api/tool_executor.zig | 135 ++++++++++++++++ src/tri-api/tool_protocol.zig | 286 ++++++++++++++++++++++++++++++++++ 4 files changed, 642 insertions(+) create mode 100644 src/tri-api/main.zig create mode 100644 src/tri-api/tool_executor.zig create mode 100644 src/tri-api/tool_protocol.zig diff --git a/build.zig b/build.zig index 4fcdd10091..02e4124bd0 100644 --- a/build.zig +++ b/build.zig @@ -2205,4 +2205,22 @@ pub fn build(b: *std.Build) void { // Also run as part of build step // b.getInstallStep().dependOn(&run_registry_export.step); + // ═══════════════════════════════════════════════════════════════════════════════ + // TRI-API — Direct Anthropic API Agent (Issue #60) + // ═══════════════════════════════════════════════════════════════════════════════ + + const tri_api = b.addExecutable(.{ + .name = "tri-api", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/tri-api/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + b.installArtifact(tri_api); + const run_tri_api = b.addRunArtifact(tri_api); + if (b.args) |args| run_tri_api.addArgs(args); + const tri_api_step = b.step("tri-api", "Run TRI-API — Direct Anthropic API Agent"); + tri_api_step.dependOn(&run_tri_api.step); + } diff --git a/src/tri-api/main.zig b/src/tri-api/main.zig new file mode 100644 index 0000000000..bec2ab8ed9 --- /dev/null +++ b/src/tri-api/main.zig @@ -0,0 +1,203 @@ +// main.zig — TRI-API: Direct Anthropic API agentic loop +// No claude CLI dependency. Talks to api.anthropic.com/v1/messages directly. +// Self-contained in src/tri-api/. Issue #60. +const std = @import("std"); +const proto = @import("tool_protocol.zig"); +const executor = @import("tool_executor.zig"); + +const api_url = "https://api.anthropic.com/v1/messages"; +const api_version = "2023-06-01"; +const max_turns = 20; +const default_model = "claude-sonnet-4-20250514"; + +pub fn main() !void { + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Read API key + const api_key = std.process.getEnvVarOwned(allocator, "ANTHROPIC_API_KEY") catch { + std.debug.print("error: ANTHROPIC_API_KEY not set\n", .{}); + std.process.exit(1); + }; + defer allocator.free(api_key); + + // Parse CLI args: [--model ] + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + var model: []const u8 = default_model; + var prompt_start: usize = 1; // skip argv[0] + + if (args.len > 2 and std.mem.eql(u8, args[1], "--model")) { + model = args[2]; + prompt_start = 3; + } + + if (prompt_start >= args.len) { + std.debug.print("usage: tri-api [--model ] \n", .{}); + std.process.exit(1); + } + + // Join remaining args as prompt + const prompt = std.mem.join(allocator, " ", args[prompt_start..]) catch { + std.debug.print("error: out of memory\n", .{}); + std.process.exit(1); + }; + defer allocator.free(prompt); + + std.debug.print("[tri-api] model={s} prompt={d} chars\n", .{ model, prompt.len }); + + // Build conversation: messages accumulate across turns + var messages = std.ArrayList(u8).empty; + defer messages.deinit(allocator); + + // Initial user message + try messages.appendSlice(allocator, "[{\"role\":\"user\",\"content\":\""); + try proto.writeJsonEscaped(messages.writer(allocator), prompt); + try messages.appendSlice(allocator, "\"}"); + + var tool_exec = executor.ToolExecutor{ .allocator = allocator }; + var total_input_tokens: u32 = 0; + var total_output_tokens: u32 = 0; + + // Agentic loop + var turn: u32 = 0; + while (turn < max_turns) : (turn += 1) { + // Close messages array + var request_body = std.ArrayList(u8).empty; + defer request_body.deinit(allocator); + + try request_body.appendSlice(allocator, "{\"model\":\""); + try request_body.appendSlice(allocator, model); + try request_body.appendSlice(allocator, "\",\"max_tokens\":8192,\"tools\":"); + try proto.writeToolDefinitions(request_body.writer(allocator)); + try request_body.appendSlice(allocator, ",\"messages\":"); + try request_body.appendSlice(allocator, messages.items); + try request_body.appendSlice(allocator, "]}"); + + std.debug.print("[tri-api] turn {d}: sending {d} bytes...\n", .{ turn + 1, request_body.items.len }); + + // POST to Anthropic API + const response_body = httpPost(allocator, api_key, request_body.items) catch |err| { + std.debug.print("[tri-api] HTTP error: {s}\n", .{@errorName(err)}); + break; + }; + defer allocator.free(response_body); + + // Parse response + var parsed = proto.parseResponse(allocator, response_body); + defer parsed.deinit(allocator); + + total_input_tokens += parsed.input_tokens; + total_output_tokens += parsed.output_tokens; + + // Process content blocks + var has_tool_use = false; + + // Build assistant message for conversation history + try messages.appendSlice(allocator, ",{\"role\":\"assistant\",\"content\":"); + try messages.appendSlice(allocator, extractContentArray(response_body) orelse "[]"); + try messages.appendSlice(allocator, "}"); + + for (parsed.blocks.items) |block| { + switch (block) { + .text => |text| { + const stdout_file = std.fs.File.stdout(); + var write_buf: [4096]u8 = undefined; + var w = stdout_file.writer(&write_buf); + std.Io.Writer.writeAll(&w.interface, text) catch {}; + std.Io.Writer.writeAll(&w.interface, "\n") catch {}; + w.end() catch {}; + }, + .tool_use => |tool| { + has_tool_use = true; + std.debug.print("[tri-api] tool: {s}({s})\n", .{ tool.name, tool.id }); + + const tool_name = executor.ToolName.fromString(tool.name) orelse { + std.debug.print("[tri-api] unknown tool: {s}\n", .{tool.name}); + continue; + }; + + const result = tool_exec.execute(tool_name, tool.input_json); + + // Append tool result to messages + try messages.appendSlice(allocator, ",{\"role\":\"user\",\"content\":["); + try proto.writeToolResult(messages.writer(allocator), tool.id, result.output, result.is_error); + try messages.appendSlice(allocator, "]}"); + }, + } + } + + // Check stop condition + if (std.mem.eql(u8, parsed.stop_reason, "end_turn") or !has_tool_use) { + std.debug.print("[tri-api] done: {s}\n", .{parsed.stop_reason}); + break; + } + } + + std.debug.print("[tri-api] {d} turns, {d} input + {d} output tokens\n", .{ turn + 1, total_input_tokens, total_output_tokens }); +} + +/// Extract the raw "content":[...] array from response body. +fn extractContentArray(body: []const u8) ?[]const u8 { + const needle = "\"content\":["; + const idx = std.mem.indexOf(u8, body, needle) orelse return null; + const start = idx + "\"content\":".len; + // Find matching ] + var depth: u32 = 0; + var end = start; + var in_string = false; + while (end < body.len) : (end += 1) { + if (in_string) { + if (body[end] == '"' and (end == 0 or body[end - 1] != '\\')) in_string = false; + continue; + } + switch (body[end]) { + '"' => in_string = true, + '[' => depth += 1, + ']' => { + depth -= 1; + if (depth == 0) return body[start .. end + 1]; + }, + else => {}, + } + } + return null; +} + +/// POST JSON to Anthropic Messages API using Zig 0.15 std.http.Client. +fn httpPost(allocator: std.mem.Allocator, api_key: []const u8, body: []const u8) ![]const u8 { + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + const uri = std.Uri.parse(api_url) catch unreachable; + + var req = client.request(.POST, uri, .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "application/json" }, + .{ .name = "x-api-key", .value = api_key }, + .{ .name = "anthropic-version", .value = api_version }, + }, + }) catch return error.ConnectionFailed; + defer req.deinit(); + + req.transfer_encoding = .{ .content_length = body.len }; + var body_writer = req.sendBodyUnflushed(&.{}) catch return error.ConnectionFailed; + body_writer.writer.writeAll(body) catch return error.ConnectionFailed; + body_writer.end() catch return error.ConnectionFailed; + if (req.connection) |conn| conn.flush() catch {}; + + var redirect_buf: [0]u8 = .{}; + var response = req.receiveHead(&redirect_buf) catch return error.ConnectionFailed; + + if (@intFromEnum(response.head.status) >= 400) { + std.debug.print("[tri-api] API status: {d}\n", .{@intFromEnum(response.head.status)}); + } + + var transfer_buf: [8192]u8 = undefined; + var reader = response.reader(&transfer_buf); + const resp_body = reader.allocRemaining(allocator, std.Io.Limit.limited(10 * 1024 * 1024)) catch return error.OutOfMemory; + + return resp_body; +} diff --git a/src/tri-api/tool_executor.zig b/src/tri-api/tool_executor.zig new file mode 100644 index 0000000000..78e28fe892 --- /dev/null +++ b/src/tri-api/tool_executor.zig @@ -0,0 +1,135 @@ +// tool_executor.zig — Execute tools (read_file, write_file, bash, grep) via std +// Self-contained: no cross-directory imports. Uses std.fs + std.process.Child only. +const std = @import("std"); +const json = @import("tool_protocol.zig"); + +pub const ToolName = enum { + read_file, + write_file, + bash, + grep, + + pub fn fromString(name: []const u8) ?ToolName { + if (std.mem.eql(u8, name, "read_file")) return .read_file; + if (std.mem.eql(u8, name, "write_file")) return .write_file; + if (std.mem.eql(u8, name, "bash")) return .bash; + if (std.mem.eql(u8, name, "grep")) return .grep; + return null; + } +}; + +pub const ToolResult = struct { + output: []const u8, + is_error: bool, +}; + +pub const ToolExecutor = struct { + allocator: std.mem.Allocator, + + pub fn execute(self: *ToolExecutor, name: ToolName, input_json: []const u8) ToolResult { + return switch (name) { + .read_file => self.readFile(input_json), + .write_file => self.writeFile(input_json), + .bash => self.runBash(input_json), + .grep => self.runGrep(input_json), + }; + } + + fn readFile(self: *ToolExecutor, input_json: []const u8) ToolResult { + const path = json.extractField(input_json, "path") orelse + return .{ .output = "error: missing 'path' field", .is_error = true }; + + const file = std.fs.cwd().openFile(path, .{}) catch |err| + return self.errResult("read_file: open failed: ", err); + + defer file.close(); + + const content = file.readToEndAlloc(self.allocator, 512 * 1024) catch |err| + return self.errResult("read_file: read failed: ", err); + + return .{ .output = content, .is_error = false }; + } + + fn writeFile(self: *ToolExecutor, input_json: []const u8) ToolResult { + const path = json.extractField(input_json, "path") orelse + return .{ .output = "error: missing 'path' field", .is_error = true }; + const content = json.extractField(input_json, "content") orelse + return .{ .output = "error: missing 'content' field", .is_error = true }; + + // Unescape JSON string content (handle \n, \t, \\, \") + const unescaped = json.unescapeString(self.allocator, content) catch + return .{ .output = "error: unescape failed", .is_error = true }; + defer self.allocator.free(unescaped); + + const file = std.fs.cwd().createFile(path, .{}) catch |err| + return self.errResult("write_file: create failed: ", err); + defer file.close(); + + file.writeAll(unescaped) catch |err| + return self.errResult("write_file: write failed: ", err); + + const msg = std.fmt.allocPrint(self.allocator, "wrote {d} bytes to {s}", .{ unescaped.len, path }) catch + return .{ .output = "wrote file", .is_error = false }; + return .{ .output = msg, .is_error = false }; + } + + fn runBash(self: *ToolExecutor, input_json: []const u8) ToolResult { + const command = json.extractField(input_json, "command") orelse + return .{ .output = "error: missing 'command' field", .is_error = true }; + + const result = std.process.Child.run(.{ + .allocator = self.allocator, + .argv = &.{ "sh", "-c", command }, + .max_output_bytes = 512 * 1024, + }) catch |err| return self.errResult("bash: spawn failed: ", err); + + defer self.allocator.free(result.stderr); + + // If non-zero exit, combine stderr + stdout + if (result.term.Exited != 0) { + defer self.allocator.free(result.stdout); + const combined = std.fmt.allocPrint( + self.allocator, + "exit code {d}\n{s}{s}", + .{ result.term.Exited, result.stderr, result.stdout }, + ) catch return .{ .output = "bash: error", .is_error = true }; + return .{ .output = combined, .is_error = true }; + } + + return .{ .output = result.stdout, .is_error = false }; + } + + fn runGrep(self: *ToolExecutor, input_json: []const u8) ToolResult { + const pattern = json.extractField(input_json, "pattern") orelse + return .{ .output = "error: missing 'pattern' field", .is_error = true }; + const path = json.extractField(input_json, "path") orelse "."; + + const result = std.process.Child.run(.{ + .allocator = self.allocator, + .argv = &.{ "grep", "-rn", pattern, path }, + .max_output_bytes = 512 * 1024, + }) catch |err| return self.errResult("grep: spawn failed: ", err); + + defer self.allocator.free(result.stderr); + + // grep returns exit 1 for "no matches" — not an error + if (result.stdout.len == 0) { + self.allocator.free(result.stdout); + return .{ .output = "no matches found", .is_error = false }; + } + + return .{ .output = result.stdout, .is_error = false }; + } + + fn errResult(self: *ToolExecutor, prefix: []const u8, err: anyerror) ToolResult { + const msg = std.fmt.allocPrint(self.allocator, "{s}{s}", .{ prefix, @errorName(err) }) catch + return .{ .output = prefix, .is_error = true }; + return .{ .output = msg, .is_error = true }; + } +}; + +test "ToolName.fromString" { + try std.testing.expectEqual(ToolName.read_file, ToolName.fromString("read_file").?); + try std.testing.expectEqual(ToolName.bash, ToolName.fromString("bash").?); + try std.testing.expect(ToolName.fromString("unknown") == null); +} diff --git a/src/tri-api/tool_protocol.zig b/src/tri-api/tool_protocol.zig new file mode 100644 index 0000000000..c87d1b30d2 --- /dev/null +++ b/src/tri-api/tool_protocol.zig @@ -0,0 +1,286 @@ +// tool_protocol.zig — JSON build/parse for Anthropic Messages API tool_use protocol +// Manual string scanning — no third-party JSON parser, same pattern as bot/json_utils.zig. +const std = @import("std"); + +// ─── Types ─────────────────────────────────────────────────────────────────── + +pub const ToolUseBlock = struct { + id: []const u8, + name: []const u8, + input_json: []const u8, +}; + +pub const ContentBlock = union(enum) { + text: []const u8, + tool_use: ToolUseBlock, +}; + +pub const ParsedResponse = struct { + blocks: std.ArrayList(ContentBlock), + stop_reason: []const u8, // "end_turn" | "tool_use" | "max_tokens" + input_tokens: u32, + output_tokens: u32, + + pub fn deinit(self: *ParsedResponse, allocator: std.mem.Allocator) void { + self.blocks.deinit(allocator); + } +}; + +// ─── Response parsing ──────────────────────────────────────────────────────── + +/// Parse an Anthropic Messages API response body into typed blocks. +/// Scans for "type":"text" and "type":"tool_use" content blocks. +pub fn parseResponse(allocator: std.mem.Allocator, body: []const u8) ParsedResponse { + var result = ParsedResponse{ + .blocks = std.ArrayList(ContentBlock).empty, + .stop_reason = "end_turn", + .input_tokens = 0, + .output_tokens = 0, + }; + + // Extract stop_reason + if (extractField(body, "stop_reason")) |sr| { + result.stop_reason = sr; + } + + // Extract usage tokens + if (extractField(body, "input_tokens")) |it| { + result.input_tokens = std.fmt.parseInt(u32, it, 10) catch 0; + } + if (extractField(body, "output_tokens")) |ot| { + result.output_tokens = std.fmt.parseInt(u32, ot, 10) catch 0; + } + + // Find content blocks by scanning for "type":"text" and "type":"tool_use" + var pos: usize = 0; + while (pos < body.len) { + // Look for "type":"text" + if (std.mem.indexOfPos(u8, body, pos, "\"type\":\"text\"")) |idx| { + const tool_idx = std.mem.indexOfPos(u8, body, pos, "\"type\":\"tool_use\"") orelse body.len; + if (idx < tool_idx) { + // Text block — extract "text" field near this position + const block_start = if (idx >= 50) idx - 50 else 0; + const block_end = @min(idx + 8192, body.len); + const block = body[block_start..block_end]; + if (extractFieldAfter(block, "text", "\"type\":\"text\"")) |text_val| { + result.blocks.append(allocator, .{ .text = text_val }) catch {}; + } + pos = idx + 12; + continue; + } + } + + // Look for "type":"tool_use" + if (std.mem.indexOfPos(u8, body, pos, "\"type\":\"tool_use\"")) |idx| { + const block_end = findBlockEnd(body, idx); + const block = body[idx..block_end]; + + const id = extractField(block, "id") orelse "unknown"; + const name = extractField(block, "name") orelse "unknown"; + // Extract input as raw JSON object + const input_json = extractObject(body, idx, "input") orelse "{}"; + + result.blocks.append(allocator, .{ .tool_use = .{ + .id = id, + .name = name, + .input_json = input_json, + } }) catch {}; + pos = block_end; + continue; + } + + break; // No more content blocks + } + + return result; +} + +// ─── Request building ──────────────────────────────────────────────────────── + +/// Write tool definitions JSON array for the 4 supported tools. +pub fn writeToolDefinitions(writer: anytype) !void { + try writer.writeAll( + \\[{"name":"read_file","description":"Read a file at the given path","input_schema":{"type":"object","properties":{"path":{"type":"string","description":"File path to read"}},"required":["path"]}}, + \\{"name":"write_file","description":"Write content to a file","input_schema":{"type":"object","properties":{"path":{"type":"string","description":"File path"},"content":{"type":"string","description":"Content to write"}},"required":["path","content"]}}, + \\{"name":"bash","description":"Run a bash command","input_schema":{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute"}},"required":["command"]}}, + \\{"name":"grep","description":"Search files with grep -rn","input_schema":{"type":"object","properties":{"pattern":{"type":"string","description":"Search pattern"},"path":{"type":"string","description":"Directory to search (default: .)"}},"required":["pattern"]}}] + ); +} + +/// Write a tool_result content block as JSON. +pub fn writeToolResult(writer: anytype, tool_use_id: []const u8, content: []const u8, is_error: bool) !void { + try writer.writeAll("{\"type\":\"tool_result\",\"tool_use_id\":\""); + try writer.writeAll(tool_use_id); + try writer.writeAll("\",\"content\":\""); + try writeJsonEscaped(writer, content); + try writer.writeByte('"'); + if (is_error) { + try writer.writeAll(",\"is_error\":true"); + } + try writer.writeByte('}'); +} + +/// Write a JSON-escaped string (handles \n, \r, \t, \\, \", control chars). +pub fn writeJsonEscaped(writer: anytype, s: []const u8) !void { + for (s) |c| { + switch (c) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\r' => try writer.writeAll("\\r"), + '\t' => try writer.writeAll("\\t"), + 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => { + try writer.print("\\u{x:0>4}", .{c}); + }, + else => try writer.writeByte(c), + } + } +} + +// ─── JSON field extraction (shared with tool_executor) ─────────────────────── + +/// Extract a JSON string value: "key":"value" → "value" +pub fn extractField(data: []const u8, key: []const u8) ?[]const u8 { + var needle_buf: [128]u8 = undefined; + const needle = std.fmt.bufPrint(&needle_buf, "\"{s}\":\"", .{key}) catch return null; + + const idx = std.mem.indexOf(u8, data, needle) orelse return null; + const start = idx + needle.len; + if (start >= data.len) return null; + + var end = start; + while (end < data.len) : (end += 1) { + if (data[end] == '"' and (end == start or data[end - 1] != '\\')) break; + } + if (end == start) return null; + return data[start..end]; +} + +/// Extract a JSON string field that appears AFTER a marker string. +fn extractFieldAfter(data: []const u8, key: []const u8, after: []const u8) ?[]const u8 { + const marker_pos = std.mem.indexOf(u8, data, after) orelse return null; + const search_region = data[marker_pos..]; + return extractField(search_region, key); +} + +/// Extract a JSON object value: "key":{...} → {...} +fn extractObject(data: []const u8, search_start: usize, key: []const u8) ?[]const u8 { + var needle_buf: [128]u8 = undefined; + const needle = std.fmt.bufPrint(&needle_buf, "\"{s}\":", .{key}) catch return null; + + const idx = std.mem.indexOfPos(u8, data, search_start, needle) orelse return null; + var start = idx + needle.len; + + // Skip whitespace + while (start < data.len and (data[start] == ' ' or data[start] == '\t' or data[start] == '\n')) : (start += 1) {} + if (start >= data.len or data[start] != '{') return null; + + // Match braces + var depth: u32 = 0; + var end = start; + var in_string = false; + while (end < data.len) : (end += 1) { + if (in_string) { + if (data[end] == '"' and (end == 0 or data[end - 1] != '\\')) in_string = false; + continue; + } + switch (data[end]) { + '"' => in_string = true, + '{' => depth += 1, + '}' => { + depth -= 1; + if (depth == 0) return data[start .. end + 1]; + }, + else => {}, + } + } + return null; +} + +/// Find the end of a content block (next "type": or end of content array). +fn findBlockEnd(data: []const u8, start: usize) usize { + // Look for next "type" after current position + const search_start = start + 10; + if (search_start >= data.len) return data.len; + if (std.mem.indexOfPos(u8, data, search_start, "\"type\":")) |next| { + // Walk back to find the comma or bracket before it + var pos = next; + while (pos > start and data[pos] != '{') : (pos -= 1) {} + return pos; + } + return data.len; +} + +/// Unescape a JSON string: \\n → \n, \\t → \t, \\\\ → \\, \\" → " +pub fn unescapeString(allocator: std.mem.Allocator, s: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + var i: usize = 0; + while (i < s.len) : (i += 1) { + if (s[i] == '\\' and i + 1 < s.len) { + switch (s[i + 1]) { + 'n' => { + out.append(allocator, '\n') catch return error.OutOfMemory; + i += 1; + }, + 't' => { + out.append(allocator, '\t') catch return error.OutOfMemory; + i += 1; + }, + 'r' => { + out.append(allocator, '\r') catch return error.OutOfMemory; + i += 1; + }, + '\\' => { + out.append(allocator, '\\') catch return error.OutOfMemory; + i += 1; + }, + '"' => { + out.append(allocator, '"') catch return error.OutOfMemory; + i += 1; + }, + else => out.append(allocator, s[i]) catch return error.OutOfMemory, + } + } else { + out.append(allocator, s[i]) catch return error.OutOfMemory; + } + } + return out.toOwnedSlice(allocator) catch return error.OutOfMemory; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +test "extractField basic" { + const data = "{\"name\":\"hello\",\"value\":\"world\"}"; + try std.testing.expectEqualStrings("hello", extractField(data, "name").?); + try std.testing.expectEqualStrings("world", extractField(data, "value").?); + try std.testing.expect(extractField(data, "missing") == null); +} + +test "extractField stop_reason" { + const data = + \\{"stop_reason":"tool_use","usage":{"input_tokens":100}} + ; + try std.testing.expectEqualStrings("tool_use", extractField(data, "stop_reason").?); +} + +test "extractObject basic" { + const data = + \\{"name":"read_file","input":{"path":"build.zig"}} + ; + const obj = extractObject(data, 0, "input").?; + try std.testing.expectEqualStrings("{\"path\":\"build.zig\"}", obj); +} + +test "unescapeString" { + const allocator = std.testing.allocator; + const result = try unescapeString(allocator, "hello\\nworld\\t!"); + defer allocator.free(result); + try std.testing.expectEqualStrings("hello\nworld\t!", result); +} + +test "writeJsonEscaped" { + var buf: [256]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + try writeJsonEscaped(fbs.writer(), "line1\nline2\ttab\"quote"); + try std.testing.expectEqualStrings("line1\\nline2\\ttab\\\"quote", fbs.getWritten()); +}