script: unify heal formatting and move TTY helpers

- Merge `formatHealReplacement` and `formatHealReplacementLines` using
  a new `HealBody` union.
- Move `interactiveTty` and `promptNumberedChoice` to `Terminal.zig`.
- Relocate and consolidate tests for `canHeal` and `isRecorded`.
- Make internal `Schema` functions private.
This commit is contained in:
Adrià Arrufat
2026-05-22 16:19:46 +02:00
parent 486d0d53a9
commit 1309407e01
6 changed files with 135 additions and 189 deletions

View File

@@ -730,7 +730,7 @@ fn runActionEntry(self: *Agent, sa: std.mem.Allocator, entry: script.Iterator.En
// opener alone is useless to the LLM — feed it the full block body.
const failed_text = std.mem.trimRight(u8, entry.raw_span, &std.ascii.whitespace);
if (self.attemptSelfHeal(sa, failed_text, reason, last_comment)) |healed_cmds| {
const replacement = script.formatHealReplacement(sa, entry.raw_span, entry.opener_line, healed_cmds) catch |err| {
const replacement = script.formatHealReplacement(sa, entry.raw_span, entry.opener_line, .{ .cmds = healed_cmds }) catch |err| {
self.terminal.printErrorFmt(
"line {d}: failed to record heal: {s} (script left unchanged)",
.{ entry.line_num, @errorName(err) },
@@ -1258,7 +1258,7 @@ fn defaultModel(p: Config.AiProvider) []const u8 {
}
fn pickProvider(found: []const Credentials) !Credentials {
if (!interactiveTty()) {
if (!Terminal.interactiveTty()) {
log.fatal(.app, "multiple API keys detected", .{
.hint = "Pass --provider explicitly when running non-interactively",
});
@@ -1268,7 +1268,7 @@ fn pickProvider(found: []const Credentials) !Credentials {
var labels: [@typeInfo(Config.AiProvider).@"enum".fields.len][]const u8 = undefined;
for (found, 0..) |f, i| labels[i] = @tagName(f.provider);
const idx = promptNumberedChoice("Multiple API keys detected. Pick provider:", labels[0..found.len], null) catch {
const idx = Terminal.promptNumberedChoice("Multiple API keys detected. Pick provider:", labels[0..found.len], null) catch {
std.debug.print("Cancelled — pass --provider to skip the picker.\n", .{});
return error.UserCancelled;
};
@@ -1280,7 +1280,7 @@ fn pickProvider(found: []const Credentials) !Credentials {
/// heap buffer (including for the default case) so the caller has one
/// uniform free path.
fn pickModel(allocator: std.mem.Allocator, llm: Credentials, base_url: ?[:0]const u8) ![]u8 {
if (!interactiveTty()) {
if (!Terminal.interactiveTty()) {
log.fatal(.app, "pick-model needs a TTY", .{
.hint = "rerun in a terminal or pass --model explicitly",
});
@@ -1314,75 +1314,9 @@ fn pickModel(allocator: std.mem.Allocator, llm: Credentials, base_url: ?[:0]cons
const header = std.fmt.bufPrint(&header_buf, "Pick model for {s}{s}:", .{ @tagName(llm.provider), enter_hint }) catch
"Pick model:";
const idx = promptNumberedChoice(header, ids, default_idx) catch {
const idx = Terminal.promptNumberedChoice(header, ids, default_idx) catch {
std.debug.print("Cancelled — pass --model to skip the picker.\n", .{});
return error.UserCancelled;
};
return try allocator.dupe(u8, ids[idx]);
}
fn interactiveTty() bool {
return std.posix.isatty(std.posix.STDIN_FILENO) and std.posix.isatty(std.posix.STDERR_FILENO);
}
/// Numbered TTY picker. `default` (if set) marks that row "(default)" and
/// makes Enter return that index. Errors with NoChoice after 3 invalid
/// attempts.
fn promptNumberedChoice(header: []const u8, items: []const []const u8, default: ?usize) !usize {
var stdin_buf: [128]u8 = undefined;
var stdin = std.fs.File.stdin().reader(&stdin_buf);
var attempt: u8 = 0;
while (attempt < 3) : (attempt += 1) {
std.debug.print("{s}\n", .{header});
for (items, 0..) |item, idx| {
const marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else "";
std.debug.print(" {d:>3}) {s}{s}\n", .{ idx + 1, item, marker });
}
std.debug.print("> ", .{});
const line = stdin.interface.takeDelimiterInclusive('\n') catch |err| switch (err) {
error.EndOfStream, error.StreamTooLong, error.ReadFailed => return error.UserCancelled,
};
const trimmed = std.mem.trim(u8, line, " \t\r\n");
if (trimmed.len == 0) {
if (default) |d| return d;
std.debug.print("Invalid input — type a number.\n", .{});
continue;
}
const choice = std.fmt.parseInt(usize, trimmed, 10) catch {
const hint: []const u8 = if (default != null) " (or press Enter for default)" else "";
std.debug.print("Invalid input — type a number{s}.\n", .{hint});
continue;
};
if (choice >= 1 and choice <= items.len) return choice - 1;
std.debug.print("Out of range.\n", .{});
}
return error.NoChoice;
}
// --- Tests ---
test "canHeal: only page-local DOM commands are allowed" {
// Table-driven over the live tool flags so adding a new tool can't
// silently drift from the heal allow-list.
const allow = [_]BrowserTool{ .click, .hover, .waitForSelector, .fill, .selectOption, .setChecked, .scroll, .extract, .press };
const deny = [_]BrowserTool{ .goto, .eval, .tree, .markdown, .search, .links };
for (allow) |action| {
const cmd = Command.fromToolCall(action, null);
try std.testing.expect(cmd.canHeal());
}
for (deny) |action| {
const cmd = Command.fromToolCall(action, null);
try std.testing.expect(!cmd.canHeal());
}
try std.testing.expect(!(Command{ .login = {} }).canHeal());
try std.testing.expect(!(Command{ .acceptCookies = {} }).canHeal());
try std.testing.expect(!(Command{ .comment = {} }).canHeal());
}
test {
_ = @import("SlashCommand.zig");
}

View File

@@ -610,6 +610,46 @@ pub fn freeLine(line: []const u8) void {
c.ic_free(@ptrCast(@constCast(line.ptr)));
}
pub fn interactiveTty() bool {
return std.posix.isatty(std.posix.STDIN_FILENO) and std.posix.isatty(std.posix.STDERR_FILENO);
}
/// Numbered TTY picker. `default` (if set) marks that row "(default)" and
/// makes Enter return that index. Errors with NoChoice after 3 invalid
/// attempts.
pub fn promptNumberedChoice(header: []const u8, items: []const []const u8, default: ?usize) !usize {
var stdin_buf: [128]u8 = undefined;
var stdin = std.fs.File.stdin().reader(&stdin_buf);
var attempt: u8 = 0;
while (attempt < 3) : (attempt += 1) {
std.debug.print("{s}\n", .{header});
for (items, 0..) |item, idx| {
const marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else "";
std.debug.print(" {d:>3}) {s}{s}\n", .{ idx + 1, item, marker });
}
std.debug.print("> ", .{});
const line = stdin.interface.takeDelimiterInclusive('\n') catch |err| switch (err) {
error.EndOfStream, error.StreamTooLong, error.ReadFailed => return error.UserCancelled,
};
const trimmed = std.mem.trim(u8, line, " \t\r\n");
if (trimmed.len == 0) {
if (default) |d| return d;
std.debug.print("Invalid input — type a number.\n", .{});
continue;
}
const choice = std.fmt.parseInt(usize, trimmed, 10) catch {
const hint: []const u8 = if (default != null) " (or press Enter for default)" else "";
std.debug.print("Invalid input — type a number{s}.\n", .{hint});
continue;
};
if (choice >= 1 and choice <= items.len) return choice - 1;
std.debug.print("Out of range.\n", .{});
}
return error.NoChoice;
}
pub fn printAssistant(_: *Terminal, text: []const u8) void {
const fd = std.posix.STDOUT_FILENO;
_ = std.posix.write(fd, text) catch {};

View File

@@ -335,7 +335,7 @@ fn handleScriptHeal(server: *Server, arena: std.mem.Allocator, id: std.json.Valu
return sendErrorContent(server, id, msg);
}
splices[i] = script.formatHealReplacementLines(arena, entry.span, spec.original_line, spec.replacement_lines) catch |err|
splices[i] = script.formatHealReplacement(arena, entry.span, spec.original_line, .{ .lines = spec.replacement_lines }) catch |err|
return sendErrorContent(server, id, @errorName(err));
}

View File

@@ -186,47 +186,36 @@ pub fn writeAtomic(
try af.finish();
}
/// Replacement body: either parsed Commands (agent self-heal) or pre-rendered
/// lines (MCP `script_heal`, where the LLM driver supplies raw PandaScript).
pub const HealBody = union(enum) {
cmds: []const Command,
lines: []const []const u8,
};
/// Build the standard `# [Auto-healed] Original: <line>` header followed by
/// the serialized replacement commands. Caller owns the returned slice.
/// the body. Caller owns the returned slice.
pub fn formatHealReplacement(
arena: std.mem.Allocator,
original_span: []const u8,
opener_line: []const u8,
cmds: []const Command,
body: HealBody,
) !Replacement {
std.debug.assert(cmds.len > 0);
var aw: std.Io.Writer.Allocating = .init(arena);
try aw.writer.print("# [Auto-healed] Original: {s}\n", .{opener_line});
for (cmds) |cmd| {
try cmd.format(&aw.writer);
try aw.writer.writeByte('\n');
switch (body) {
.cmds => |cmds| for (cmds) |cmd| {
try cmd.format(&aw.writer);
try aw.writer.writeByte('\n');
},
.lines => |lines| for (lines) |line| {
try aw.writer.writeAll(line);
try aw.writer.writeByte('\n');
},
}
return .{ .original_span = original_span, .new_text = aw.written() };
}
/// Same shape as `formatHealReplacement` but for callers that already have
/// rendered replacement lines (no Command round-trip). Used by the MCP
/// `script_heal` tool where the LLM driver supplies raw PandaScript lines.
pub fn formatHealReplacementLines(
arena: std.mem.Allocator,
original_span: []const u8,
opener_line: []const u8,
replacement_lines: []const []const u8,
) !Replacement {
var aw: std.Io.Writer.Allocating = .init(arena);
try aw.writer.print("# [Auto-healed] Original: {s}\n", .{opener_line});
for (replacement_lines) |line| {
try aw.writer.writeAll(line);
try aw.writer.writeByte('\n');
}
return .{
.original_span = original_span,
.new_text = aw.written(),
};
}
/// Reject paths that an untrusted MCP client could use to escape the
/// working directory: empty paths, absolute paths, and any path with a
/// `..` segment. Operator-controlled symlinks already inside CWD are out
@@ -375,45 +364,31 @@ fn buildToolCall(arena: std.mem.Allocator, name: []const u8, kvs: []const struct
return .{ .tool_call = .{ .tool = tool, .args = .{ .object = obj } } };
}
test "formatHealReplacement: single command produces one-line replacement" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const cmds = [_]Command{buildToolCall(arena.allocator(), "click", &.{.{ "selector", "#submit-v2" }})};
const replacement = try formatHealReplacement(
arena.allocator(),
"/click selector='#submit'\n",
"/click selector='#submit'",
&cmds,
);
try std.testing.expectEqualStrings("/click selector='#submit'\n", replacement.original_span);
try std.testing.expectEqualStrings(
"# [Auto-healed] Original: /click selector='#submit'\n/click selector='#submit-v2'\n",
replacement.new_text,
);
}
test "formatHealReplacement: multiple commands produce multi-line replacement" {
test "formatHealReplacement: single and multiple commands" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
const cmds = [_]Command{
buildToolCall(aa, "click", &.{.{ "selector", ".cookie-accept" }}),
buildToolCall(aa, "click", &.{.{ "selector", "#submit-v2" }}),
};
const replacement = try formatHealReplacement(
aa,
"/click selector='#submit'\n",
"/click selector='#submit'",
&cmds,
);
try std.testing.expectEqualStrings(
"# [Auto-healed] Original: /click selector='#submit'\n/click selector='.cookie-accept'\n/click selector='#submit-v2'\n",
replacement.new_text,
);
{
const cmds = [_]Command{buildToolCall(aa, "click", &.{.{ "selector", "#submit-v2" }})};
const r = try formatHealReplacement(aa, "/click selector='#submit'\n", "/click selector='#submit'", .{ .cmds = &cmds });
try std.testing.expectEqualStrings("/click selector='#submit'\n", r.original_span);
try std.testing.expectEqualStrings(
"# [Auto-healed] Original: /click selector='#submit'\n/click selector='#submit-v2'\n",
r.new_text,
);
}
{
const cmds = [_]Command{
buildToolCall(aa, "click", &.{.{ "selector", ".cookie-accept" }}),
buildToolCall(aa, "click", &.{.{ "selector", "#submit-v2" }}),
};
const r = try formatHealReplacement(aa, "/click selector='#submit'\n", "/click selector='#submit'", .{ .cmds = &cmds });
try std.testing.expectEqualStrings(
"# [Auto-healed] Original: /click selector='#submit'\n/click selector='.cookie-accept'\n/click selector='#submit-v2'\n",
r.new_text,
);
}
}
test "writeAtomic: writes content and creates .bak" {

View File

@@ -244,7 +244,7 @@ pub fn parseSlashCommand(input: []const u8) ?Split {
return splitNameRest(input[1..]);
}
pub fn find(schemas: []const Schema, name: []const u8) ?*const Schema {
fn find(schemas: []const Schema, name: []const u8) ?*const Schema {
if (std.meta.stringToEnum(BrowserTool, name)) |tool| {
const idx = @intFromEnum(tool);
if (idx < schemas.len) return &schemas[idx];
@@ -452,7 +452,7 @@ pub const QuoteType = enum {
return if (s.len == 3) fromPrefix(s) else null;
}
pub fn fromPrefix(s: []const u8) ?QuoteType {
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;
@@ -467,7 +467,7 @@ pub const QuoteType = enum {
/// Pick a triple-quote delimiter not appearing in `body`. Null when
/// both appear and neither can wrap unambiguously.
pub fn pickFor(body: []const u8) ?QuoteType {
fn pickFor(body: []const u8) ?QuoteType {
const has_single = std.mem.indexOf(u8, body, "'''") != null;
const has_double = std.mem.indexOf(u8, body, "\"\"\"") != null;
if (has_single and has_double) return null;
@@ -515,7 +515,7 @@ pub fn writeInlineValue(writer: *std.Io.Writer, v: std.json.Value) (std.Io.Write
/// Caller must filter via `quotableInline` first; remaining ambiguous
/// cases trap as `WriteFailed` so a stray path can't emit a broken line.
pub fn writeQuoted(writer: *std.Io.Writer, s: []const u8) (std.Io.Writer.Error || error{AmbiguousQuoting})!void {
fn writeQuoted(writer: *std.Io.Writer, s: []const u8) (std.Io.Writer.Error || error{AmbiguousQuoting})!void {
if (std.mem.indexOfScalar(u8, s, '\n') != null) return error.WriteFailed;
const has_single = std.mem.indexOfScalar(u8, s, '\'') != null;

View File

@@ -285,24 +285,22 @@ test "format: /eval emits triple-quote block for multi-line script" {
try testing.expectString("/eval '''\nconst x = 1;\nreturn x;\n'''", aw.written());
}
test "format: /setChecked omits checked=true (matches default)" {
test "format: /setChecked omits checked=true (default), keeps checked=false" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/setChecked selector='#agree' checked=true");
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectString("/setChecked selector='#agree'", aw.written());
}
const aa = arena.allocator();
test "format: /setChecked keeps checked=false (non-default)" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const cmd = try Command.parse(arena.allocator(), "/setChecked selector='#x' checked=false");
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectString("/setChecked selector='#x' checked=false", aw.written());
const cases = [_]struct { input: []const u8, expected: []const u8 }{
.{ .input = "/setChecked selector='#agree' checked=true", .expected = "/setChecked selector='#agree'" },
.{ .input = "/setChecked selector='#x' checked=false", .expected = "/setChecked selector='#x' checked=false" },
};
for (cases) |case| {
const cmd = try Command.parse(aa, case.input);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try cmd.format(&aw.writer);
try testing.expectString(case.expected, aw.written());
}
}
test "format: /login and /acceptCookies" {
@@ -317,6 +315,26 @@ test "format: /login and /acceptCookies" {
try testing.expectString("/acceptCookies", aw2.written());
}
test "canHeal: only page-local DOM commands are allowed" {
// Table-driven over the live tool flags so adding a new tool can't
// silently drift from the heal allow-list.
const allow = [_]BrowserTool{ .click, .hover, .waitForSelector, .fill, .selectOption, .setChecked, .scroll, .extract, .press };
const deny = [_]BrowserTool{ .goto, .eval, .tree, .markdown, .search, .links };
for (allow) |action| {
const cmd = Command.fromToolCall(action, null);
try testing.expect(cmd.canHeal());
}
for (deny) |action| {
const cmd = Command.fromToolCall(action, null);
try testing.expect(!cmd.canHeal());
}
try testing.expect(!(Command{ .login = {} }).canHeal());
try testing.expect(!(Command{ .acceptCookies = {} }).canHeal());
try testing.expect(!(Command{ .comment = {} }).canHeal());
}
test "isRecorded / canHeal / producesData via tool flags" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
@@ -335,22 +353,22 @@ test "isRecorded / canHeal / producesData via tool flags" {
try testing.expect(!login.canHeal());
}
test "isRecorded: null args on a required-fields tool are not recorded" {
// A provider that hands back `arguments: null` for `/click` would
// otherwise produce a bare `/click` line that can't be replayed.
const click_null = Command.fromToolCall(.click, null);
try testing.expect(click_null.isRecorded()); // click has zero required fields
const goto_null = Command.fromToolCall(.goto, null);
try testing.expect(!goto_null.isRecorded()); // goto requires url
const fill_null = Command.fromToolCall(.fill, null);
try testing.expect(!fill_null.isRecorded()); // fill requires value
}
test "isRecorded and format: backendNodeId stripped, selector preserved" {
test "isRecorded: args shape and locator semantics" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
// Null args: recorded iff the tool has zero required fields. A provider
// that hands back `arguments: null` for `/click` would otherwise produce
// a bare `/click` line that can't be replayed.
try testing.expect(Command.fromToolCall(.click, null).isRecorded());
try testing.expect(!Command.fromToolCall(.goto, null).isRecorded());
try testing.expect(!Command.fromToolCall(.fill, null).isRecorded());
// Non-object args: recorded iff the tool doesn't need a locator.
try testing.expect(Command.fromToolCall(.goto, .{ .string = "https://x" }).isRecorded());
try testing.expect(!Command.fromToolCall(.click, .{ .string = "#submit" }).isRecorded());
// selector + backendNodeId: keep the call, drop the backendNodeId.
{
var obj: std.json.ObjectMap = .init(aa);
@@ -365,7 +383,7 @@ test "isRecorded and format: backendNodeId stripped, selector preserved" {
try testing.expectString("/click selector='#submit'", aw.written());
}
// backendNodeId only: still skipped — no replayable identifier.
// backendNodeId only: skipped — no replayable identifier.
{
var obj: std.json.ObjectMap = .init(aa);
try obj.put("backendNodeId", .{ .integer = 42 });
@@ -373,24 +391,3 @@ test "isRecorded and format: backendNodeId stripped, selector preserved" {
try testing.expect(!cmd.isRecorded());
}
}
test "fromToolCall: builds a tool_call Command" {
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
var obj: std.json.ObjectMap = .init(arena.allocator());
try obj.put("url", .{ .string = "https://x" });
const cmd = Command.fromToolCall(.goto, .{ .object = obj });
try testing.expect(cmd == .tool_call);
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());
}