tools: restructure browser tools and script schemas

- Replace `Action` enum with `Tool` enum using exhaustive switches
- Extract `ScriptIterator` to `Iterator.zig`
- Refactor `schema.zig` into `Schema.zig`
- Move string substitution logic into `tools.zig`
- Clean up `SlashCommand.zig` to only handle REPL meta-commands
This commit is contained in:
Adrià Arrufat
2026-05-22 13:41:04 +02:00
parent 6cd75c454e
commit 8fb3c7baed
13 changed files with 1691 additions and 1420 deletions

View File

@@ -16,17 +16,16 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! PandaScript Command: a slash command, `#`-comment, or `/login` /
//! `/acceptCookies` LLM trigger. Bare prose is the REPL's job, not the parser's.
//! Multi-line `'''…'''` blocks are assembled by `ScriptIterator` before parse.
//! PandaScript Command: slash command, `#`-comment, or `/login` /
//! `/acceptCookies` LLM trigger. Multi-line `'''…'''` blocks are
//! assembled by `script.Iterator` before parse.
const std = @import("std");
const lp = @import("lightpanda");
const zenai = @import("zenai");
const browser_tools = lp.tools;
const schema = @import("schema.zig");
const BrowserTool = lp.tools.Tool;
const Schema = @import("Schema.zig");
pub const ParseError = schema.ParseError || error{
pub const ParseError = Schema.ParseError || error{
NotASlashCommand,
};
@@ -37,40 +36,89 @@ pub const Command = union(enum) {
comment: void,
pub const ToolCall = struct {
action: browser_tools.Action,
tool: BrowserTool,
args: ?std.json.Value,
pub fn name(self: ToolCall) [:0]const u8 {
return @tagName(self.action);
return @tagName(self.tool);
}
pub fn schema(self: ToolCall) *const Schema {
return &Schema.all()[@intFromEnum(self.tool)];
}
/// Skip the line when the recorded form would not round-trip:
/// - no `selector` AND (tool needs one OR only locator is the
/// ephemeral `backendNodeId`);
/// - a string field can't be quoted unambiguously.
pub fn isRecorded(self: ToolCall) bool {
if (!self.tool.isRecorded()) return false;
const s = self.schema();
const args = self.args orelse return s.required.len == 0;
if (args != .object) return !self.tool.needsLocator();
const has_selector = args.object.contains("selector");
if (!has_selector and (self.tool.needsLocator() or args.object.contains("backendNodeId"))) return false;
const visible = s.visibleArgCount(args.object);
const positional = s.required.len == 1 and visible == 1 and s.isSinglePositional(args.object);
var it = args.object.iterator();
while (it.next()) |entry| {
if (s.skipForFormat(entry.key_ptr.*, entry.value_ptr.*)) continue;
if (entry.value_ptr.* != .string) continue;
const is_body = positional and std.mem.eql(u8, entry.key_ptr.*, s.required[0]);
if (!Schema.quotableInline(entry.value_ptr.string, is_body)) return false;
}
return true;
}
/// Canonical recorder format. Round-trips with `Command.parse`.
pub fn format(self: ToolCall, writer: *std.Io.Writer) (std.Io.Writer.Error || error{AmbiguousQuoting})!void {
const s = self.schema();
try writer.writeByte('/');
try writer.writeAll(s.tool_name);
const args_val = self.args orelse return;
if (args_val != .object) return;
const args = args_val.object;
if (args.count() == 0) return;
const visible = s.visibleArgCount(args);
const positional = s.required.len == 1 and visible == 1 and s.isSinglePositional(args);
if (positional) {
const v = args.get(s.required[0]).?;
try writer.writeByte(' ');
try Schema.writeBodyString(writer, v.string);
return;
}
// Iterate the schema (not the ObjectMap) so the line order is
// stable across providers — MCP script_heal looks lines up
// verbatim.
for (s.fields) |f| {
const v = args.get(f.name) orelse continue;
if (f.skipForFormat(v)) continue;
try writer.writeByte(' ');
try writer.writeAll(f.name);
try writer.writeByte('=');
try Schema.writeInlineValue(writer, v);
}
}
};
fn schemaOf(tc: ToolCall) *const schema.SchemaInfo {
return &schema.globalSchemas()[@intFromEnum(tc.action)];
}
pub fn isRecorded(self: Command) bool {
return switch (self) {
.comment => false,
.login, .accept_cookies => true,
.tool_call => |tc| blk: {
const s = schemaOf(tc);
if (!s.recorded) break :blk false;
const args = tc.args orelse break :blk s.required.len == 0;
if (args != .object) break :blk true;
// backendNodeId is invalidated by any DOM mutation, so it's
// never replayable. Drop the line only when it's the sole
// identifier; selector-bearing calls are still recordable
// (formatToolCall strips backendNodeId from the output).
if (args.object.contains("backendNodeId") and !args.object.contains("selector")) break :blk false;
break :blk true;
},
.tool_call => |tc| tc.isRecorded(),
};
}
pub fn producesData(self: Command) bool {
return switch (self) {
.tool_call => |tc| schemaOf(tc).produces_data,
.tool_call => |tc| tc.tool.producesData(),
else => false,
};
}
@@ -84,7 +132,7 @@ pub const Command = union(enum) {
pub fn canHeal(self: Command) bool {
return switch (self) {
.tool_call => |tc| schemaOf(tc).can_heal,
.tool_call => |tc| tc.tool.canHeal(),
else => false,
};
}
@@ -95,7 +143,7 @@ pub const Command = union(enum) {
if (trimmed[0] == '#') return .{ .comment = {} };
if (trimmed[0] != '/') return error.NotASlashCommand;
const split = schema.splitNameRest(trimmed[1..]) orelse return error.MissingName;
const split = Schema.splitNameRest(trimmed[1..]) orelse return error.MissingName;
if (std.ascii.eqlIgnoreCase(split.name, "login")) {
if (split.rest.len > 0) return error.MalformedKv;
@@ -106,266 +154,33 @@ pub const Command = union(enum) {
return .{ .accept_cookies = {} };
}
const s = schema.findSchema(schema.globalSchemas(), split.name) orelse return error.UnknownTool;
const args = try schema.parseValue(arena, s, split.rest);
return .{ .tool_call = .{ .action = s.action, .args = args } };
const s = Schema.find(Schema.all(), split.name) orelse return error.UnknownTool;
const args = try s.parseValue(arena, split.rest);
return .{ .tool_call = .{ .tool = s.tool, .args = args } };
}
/// Canonical recorder format. Round-trips with `parse`.
pub fn format(self: Command, writer: *std.Io.Writer) std.Io.Writer.Error!void {
pub fn format(self: Command, writer: *std.Io.Writer) (std.Io.Writer.Error || error{AmbiguousQuoting})!void {
switch (self) {
.login => try writer.writeAll("/login"),
.accept_cookies => try writer.writeAll("/acceptCookies"),
.comment => try writer.writeAll("#"),
.tool_call => |tc| try formatToolCall(tc, writer),
.tool_call => |tc| try tc.format(writer),
}
}
/// `arguments` must outlive the returned Command — use `fromToolCallOwned`
/// to deep-copy when it doesn't.
pub fn fromToolCall(action: browser_tools.Action, arguments: ?std.json.Value) Command {
return .{ .tool_call = .{ .action = action, .args = arguments } };
}
pub fn fromToolCallOwned(arena: std.mem.Allocator, action: browser_tools.Action, arguments: ?std.json.Value) std.mem.Allocator.Error!Command {
const owned_args = if (arguments) |v| try zenai.json.dupeValue(arena, v) else null;
return .{ .tool_call = .{ .action = action, .args = owned_args } };
}
/// Iterates `.lp` content, gluing multi-line `'''…'''` blocks into a
/// single entry. Comments surface as `.comment` so the replay can attach
/// the preceding comment to the next executable line.
pub const ScriptIterator = struct {
allocator: std.mem.Allocator,
lines: std.mem.SplitIterator(u8, .scalar),
line_num: u32,
pub fn init(allocator: std.mem.Allocator, content: []const u8) ScriptIterator {
return .{
.allocator = allocator,
.lines = std.mem.splitScalar(u8, content, '\n'),
.line_num = 0,
};
}
pub const Entry = struct {
line_num: u32,
/// Trimmed opener line; use `raw_span` for splices that need the
/// full block body.
opener_line: []const u8,
/// Slice of the original content buffer covering this entry,
/// trailing newline included. Multi-line blocks span opener
/// through closing triple-quote.
raw_span: []const u8,
command: Command,
};
pub fn next(self: *ScriptIterator) ParseError!?Entry {
while (self.lines.next()) |line| {
self.line_num += 1;
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
const line_start = @intFromPtr(line.ptr) - @intFromPtr(self.lines.buffer.ptr);
if (tryBlockOpener(trimmed)) |opener| {
const start_line = self.line_num;
const body = try self.collectMultiLineBlock(opener.quote_type);
const span_end = self.lines.index orelse self.lines.buffer.len;
if (body == null) {
// Point the error at the opener line, not at EOF
// (where collectMultiLineBlock left line_num after
// scanning the rest of the file for the closer).
self.line_num = start_line;
return error.UnterminatedQuote;
}
var obj: std.json.ObjectMap = .init(self.allocator);
try obj.put(opener.field, .{ .string = body.? });
return .{
.line_num = start_line,
.opener_line = trimmed,
.raw_span = self.lines.buffer[line_start..span_end],
.command = .{ .tool_call = .{
.action = opener.action,
.args = .{ .object = obj },
} },
};
}
const span_end = self.lines.index orelse self.lines.buffer.len;
return .{
.line_num = self.line_num,
.opener_line = trimmed,
.raw_span = self.lines.buffer[line_start..span_end],
.command = try Command.parse(self.allocator, trimmed),
};
}
return null;
}
const BlockOpener = struct {
action: browser_tools.Action,
field: []const u8,
quote_type: QuoteType,
};
fn tryBlockOpener(line: []const u8) ?BlockOpener {
if (line.len < 2 or line[0] != '/') return null;
const split = schema.splitNameRest(line[1..]) orelse return null;
const s = schema.findSchema(schema.globalSchemas(), split.name) orelse return null;
if (!s.isMultiLineCapable()) return null;
const qt = QuoteType.fromLiteral(split.rest) orelse return null;
return .{ .action = s.action, .field = s.required[0], .quote_type = qt };
}
fn collectMultiLineBlock(self: *ScriptIterator, quote_type: QuoteType) std.mem.Allocator.Error!?[]const u8 {
const closer = quote_type.toLiteral();
var parts: std.ArrayList(u8) = .empty;
defer parts.deinit(self.allocator);
while (self.lines.next()) |line| {
self.line_num += 1;
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (std.mem.eql(u8, trimmed, closer)) {
return try parts.toOwnedSlice(self.allocator);
}
if (parts.items.len > 0) {
try parts.append(self.allocator, '\n');
}
// Trim CR only; full trim would clobber indentation.
try parts.appendSlice(self.allocator, std.mem.trimRight(u8, line, "\r"));
}
return null;
}
};
};
// --- Formatting ---
fn formatToolCall(tc: Command.ToolCall, writer: *std.Io.Writer) std.Io.Writer.Error!void {
const s = &schema.globalSchemas()[@intFromEnum(tc.action)];
try writer.writeByte('/');
try writer.writeAll(s.tool_name);
const args_val = tc.args orelse return;
if (args_val != .object) return;
const args = args_val.object;
if (args.count() == 0) return;
// Positional form `/goto '<url>'` only when args reduce to the single
// required field; extra fields force kv so recordings stay unambiguous.
var positional_emitted: ?[]const u8 = null;
{
const has_one_required = s.required.len == 1;
var visible: usize = 0;
var it_v = args.iterator();
while (it_v.next()) |entry| {
if (skipForFormat(s, entry.key_ptr.*, entry.value_ptr.*)) continue;
visible += 1;
}
if (has_one_required and visible == 1) blk: {
const req_name = s.required[0];
const v = args.get(req_name) orelse break :blk;
if (v != .string) break :blk;
try writer.writeByte(' ');
try formatString(writer, v.string);
positional_emitted = req_name;
}
}
var it = args.iterator();
while (it.next()) |entry| {
const key = entry.key_ptr.*;
if (positional_emitted) |p| if (std.mem.eql(u8, key, p)) continue;
if (skipForFormat(s, key, entry.value_ptr.*)) continue;
try writer.writeByte(' ');
try writer.writeAll(key);
try writer.writeByte('=');
try formatKvValue(writer, entry.value_ptr.*);
}
}
/// Args that the recorder must NOT emit:
/// - `backendNodeId`: ephemeral identifier, never replayable.
/// - boolean fields whose value equals the schema default (cosmetic).
fn skipForFormat(s: *const schema.SchemaInfo, key: []const u8, v: std.json.Value) bool {
if (std.mem.eql(u8, key, "backendNodeId")) return true;
return v == .bool and v.bool and s.isFieldDefaultTrue(key);
}
fn formatString(writer: *std.Io.Writer, s: []const u8) std.Io.Writer.Error!void {
if (std.mem.indexOfScalar(u8, s, '\n') != null) {
const q = QuoteType.pickFor(s).toLiteral();
try writer.writeAll(q);
try writer.writeByte('\n');
try writer.writeAll(s);
try writer.writeByte('\n');
try writer.writeAll(q);
return;
}
try writeQuoted(writer, s);
}
fn formatKvValue(writer: *std.Io.Writer, v: std.json.Value) std.Io.Writer.Error!void {
switch (v) {
.string => |s| try formatString(writer, s),
.integer => |n| try writer.print("{d}", .{n}),
.float => |n| try writer.print("{d}", .{n}),
.bool => |b| try writer.writeAll(if (b) "true" else "false"),
.null => try writer.writeAll("null"),
else => std.json.Stringify.value(v, .{}, writer) catch return error.WriteFailed,
}
}
fn writeQuoted(writer: *std.Io.Writer, s: []const u8) std.Io.Writer.Error!void {
const has_single = std.mem.indexOfScalar(u8, s, '\'') != null;
const has_double = std.mem.indexOfScalar(u8, s, '"') != null;
if (has_single and has_double) {
const q = QuoteType.pickFor(s).toLiteral();
try writer.writeAll(q);
try writer.writeAll(s);
try writer.writeAll(q);
return;
}
const q: u8 = if (has_single) '"' else '\'';
try writer.writeByte(q);
try writer.writeAll(s);
try writer.writeByte(q);
}
// --- Quoting primitives (kept for ScriptIterator block-opener detection) ---
pub const QuoteType = enum {
triple_double,
triple_single,
pub fn fromLiteral(s: []const u8) ?QuoteType {
return if (s.len == 3) fromPrefix(s) else null;
}
pub fn fromPrefix(s: []const u8) ?QuoteType {
if (std.mem.startsWith(u8, s, "\"\"\"")) return .triple_double;
if (std.mem.startsWith(u8, s, "'''")) return .triple_single;
return null;
}
pub fn toLiteral(self: QuoteType) []const u8 {
return switch (self) {
.triple_double => "\"\"\"",
.triple_single => "'''",
};
}
/// Default `'''`; swaps to `"""` only when the body already contains `'''`.
pub fn pickFor(body: []const u8) QuoteType {
if (std.mem.indexOf(u8, body, "'''") != null) return .triple_double;
return .triple_single;
/// `arguments` must outlive the returned Command. Callers that hand the
/// Command to anything past the args' arena lifetime (e.g. heal, which
/// reuses cmds after `RunToolsResult.deinit`) must deep-copy the arguments
/// into their own arena before calling this.
pub fn fromToolCall(tool: BrowserTool, arguments: ?std.json.Value) Command {
return .{ .tool_call = .{ .tool = tool, .args = arguments } };
}
};
// --- Tests ---
const testing = std.testing;
const testing = @import("../testing.zig");
test "parse: blank and # lines are comments" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
@@ -394,8 +209,8 @@ test "parse: /goto positional" {
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/goto https://example.com");
try testing.expect(cmd == .tool_call);
try testing.expectEqualStrings("goto", cmd.tool_call.name());
try testing.expectEqualStrings("https://example.com", cmd.tool_call.args.?.object.get("url").?.string);
try testing.expectString("goto", cmd.tool_call.name());
try testing.expectString("https://example.com", cmd.tool_call.args.?.object.get("url").?.string);
}
test "parse: /click rejects positional (zero required fields)" {
@@ -403,7 +218,7 @@ test "parse: /click rejects positional (zero required fields)" {
defer arena.deinit();
try testing.expectError(error.PositionalNotAllowed, Command.parse(arena.allocator(), "/click 'Login'"));
const cmd = try Command.parse(arena.allocator(), "/click selector='Login'");
try testing.expectEqualStrings("Login", cmd.tool_call.args.?.object.get("selector").?.string);
try testing.expectString("Login", cmd.tool_call.args.?.object.get("selector").?.string);
}
test "parse: /scroll y=200" {
@@ -417,7 +232,7 @@ test "parse: /setChecked omits checked (default-true)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/setChecked selector='#agree'");
try testing.expectEqualStrings("#agree", cmd.tool_call.args.?.object.get("selector").?.string);
try testing.expectString("#agree", cmd.tool_call.args.?.object.get("selector").?.string);
try testing.expect(cmd.tool_call.args.?.object.get("checked").?.bool);
}
@@ -434,7 +249,7 @@ test "format: /goto round-trip" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/goto 'https://example.com'", aw.written());
try testing.expectString("/goto 'https://example.com'", aw.written());
}
test "format: /click stays kv (zero required fields)" {
@@ -444,7 +259,7 @@ test "format: /click stays kv (zero required fields)" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/click selector='Login'", aw.written());
try testing.expectString("/click selector='Login'", aw.written());
}
test "format: /eval emits triple-quote block for multi-line script" {
@@ -455,12 +270,12 @@ test "format: /eval emits triple-quote block for multi-line script" {
try obj.put("script", .{ .string = "const x = 1;\nreturn x;" });
break :blk std.json.Value{ .object = obj };
};
const cmd: Command = .{ .tool_call = .{ .action = .eval, .args = args } };
const cmd: Command = .{ .tool_call = .{ .tool = .eval, .args = args } };
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/eval '''\nconst x = 1;\nreturn x;\n'''", aw.written());
try testing.expectString("/eval '''\nconst x = 1;\nreturn x;\n'''", aw.written());
}
test "format: /setChecked omits checked=true (matches default)" {
@@ -470,7 +285,7 @@ test "format: /setChecked omits checked=true (matches default)" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/setChecked selector='#agree'", aw.written());
try testing.expectString("/setChecked selector='#agree'", aw.written());
}
test "format: /setChecked keeps checked=false (non-default)" {
@@ -480,19 +295,19 @@ test "format: /setChecked keeps checked=false (non-default)" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/setChecked selector='#x' checked=false", aw.written());
try testing.expectString("/setChecked selector='#x' checked=false", aw.written());
}
test "format: /login and /acceptCookies" {
var aw1: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw1.deinit();
try (Command{ .login = {} }).format(&aw1.writer);
try testing.expectEqualStrings("/login", aw1.written());
try testing.expectString("/login", aw1.written());
var aw2: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw2.deinit();
try (Command{ .accept_cookies = {} }).format(&aw2.writer);
try testing.expectEqualStrings("/acceptCookies", aw2.written());
try testing.expectString("/acceptCookies", aw2.written());
}
test "isRecorded / canHeal / producesData via tool flags" {
@@ -540,7 +355,7 @@ test "isRecorded and format: backendNodeId stripped, selector preserved" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectEqualStrings("/click selector='#submit'", aw.written());
try testing.expectString("/click selector='#submit'", aw.written());
}
// backendNodeId only: still skipped — no replayable identifier.
@@ -552,126 +367,6 @@ test "isRecorded and format: backendNodeId stripped, selector preserved" {
}
}
test "ScriptIterator: basic slash commands" {
const content =
"/goto https://example.com\n" ++
"/tree\n" ++
"/click selector='Login'\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expect(e1.command == .tool_call);
try testing.expectEqualStrings("goto", e1.command.tool_call.name());
const e2 = (try iter.next()).?;
try testing.expectEqualStrings("tree", e2.command.tool_call.name());
const e3 = (try iter.next()).?;
try testing.expectEqualStrings("click", e3.command.tool_call.name());
try testing.expect((try iter.next()) == null);
}
test "ScriptIterator: multi-line /eval block" {
const content =
"/goto https://x\n" ++
"/eval '''\n" ++
"const x = 1;\n" ++
"return x;\n" ++
"'''\n" ++
"/tree\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expectEqualStrings("goto", e1.command.tool_call.name());
const e2 = (try iter.next()).?;
try testing.expectEqualStrings("eval", e2.command.tool_call.name());
const script_value = e2.command.tool_call.args.?.object.get("script").?.string;
try testing.expect(std.mem.indexOf(u8, script_value, "const x = 1;") != null);
try testing.expect(std.mem.indexOf(u8, script_value, "return x;") != null);
const e3 = (try iter.next()).?;
try testing.expectEqualStrings("tree", e3.command.tool_call.name());
try testing.expect((try iter.next()) == null);
}
test "ScriptIterator: comments preserve opener_line for context" {
const content =
"# Navigate\n" ++
"/goto https://x\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expect(e1.command == .comment);
try testing.expectEqualStrings("# Navigate", e1.opener_line);
const e2 = (try iter.next()).?;
try testing.expect(e2.command == .tool_call);
try testing.expect((try iter.next()) == null);
}
test "ScriptIterator: bare prose in script errors" {
const content = "click the login button\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
try testing.expectError(error.NotASlashCommand, iter.next());
}
test "ScriptIterator: UnterminatedQuote reports the opener line" {
// Opener is on line 2; the closer is missing. line_num should point at
// line 2 (the opener), not at EOF where the scan stopped.
const content =
"/goto https://x\n" ++
"/eval '''\n" ++
" const x = 1;\n" ++
" return x;\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
_ = (try iter.next()).?; // /goto
try testing.expectError(error.UnterminatedQuote, iter.next());
try testing.expectEqual(@as(u32, 2), iter.line_num);
}
test "ScriptIterator: strips trailing CR from CRLF-authored bodies" {
const content = "/goto https://x\r\n/extract '''\r\n{\"t\":\"h1\"}\r\n'''\r\n/click selector='#x'\r\n";
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var iter: Command.ScriptIterator = .init(arena.allocator(), content);
const e1 = (try iter.next()).?;
try testing.expectEqualStrings("goto", e1.command.tool_call.name());
const e2 = (try iter.next()).?;
try testing.expectEqualStrings("extract", e2.command.tool_call.name());
try testing.expectEqualStrings("{\"t\":\"h1\"}", e2.command.tool_call.args.?.object.get("schema").?.string);
const e3 = (try iter.next()).?;
try testing.expectEqualStrings("click", e3.command.tool_call.name());
try testing.expect((try iter.next()) == null);
}
test "fromToolCall: builds a tool_call Command" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
@@ -680,5 +375,15 @@ test "fromToolCall: builds a tool_call Command" {
try obj.put("url", .{ .string = "https://x" });
const cmd = Command.fromToolCall(.goto, .{ .object = obj });
try testing.expect(cmd == .tool_call);
try testing.expectEqualStrings("goto", cmd.tool_call.name());
try testing.expectString("goto", cmd.tool_call.name());
}
test "isRecorded: non-object args check locator presence" {
// goto does not need a locator: isRecorded returns true even if args is not object
const goto_non_obj = Command.fromToolCall(.goto, .{ .string = "https://x" });
try testing.expect(goto_non_obj.isRecorded());
// click needs a locator: isRecorded returns false if args is not object
const click_non_obj = Command.fromToolCall(.click, .{ .string = "#submit" });
try testing.expect(!click_non_obj.isRecorded());
}