diff --git a/src/browser/tools.zig b/src/browser/tools.zig index e4515b62..d005e22f 100644 --- a/src/browser/tools.zig +++ b/src/browser/tools.zig @@ -202,7 +202,7 @@ pub const Tool = enum { \\ "url": { "type": "string", "description": "Optional URL to navigate to before evaluating." }, \\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }, \\ "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "done"], "description": "Optional wait strategy. Defaults to 'done'." }, - \\ "save": { "type": "string", "description": "Optional bridge-store key. The eval's return value is stored under this name and re-exposed as `lp.` to subsequent evals. Objects and arrays are stored as JSON automatically; a returned value must be JSON-serializable." } + \\ "save": { "type": "string", "description": "Optional bridge-store key. The eval's return value is stored under this name and re-exposed as `lp.` to subsequent evals. Objects, arrays, and strings are serialized automatically — no JSON.stringify needed." } \\ }, \\ "required": ["script"] \\} @@ -966,7 +966,7 @@ fn execEval(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.R // `let`/`const` from leaking; top-level `await`/`return` need the async IIFE. const block_script = std.fmt.allocPrintSentinel( arena, - "{{\n{s}\n}}", + "{{ {s}\n}}", .{args.script}, 0, ) catch return ToolError.OutOfMemory; @@ -991,14 +991,17 @@ fn execEval(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.R }; }; - // Silence on save= success so stdout pipes stay clean. + // Silence on save= success so stdout pipes stay clean. Objects/arrays + // already render as JSON; a bare string (or other non-JSON text) is + // JSON-encoded so it round-trips to `lp.`. if (args.save) |name| { - bridgeStoreSet(app_allocator, &session.bridge_store, name, result.text) catch |err| switch (err) { + const json_value = if (std.json.validate(arena, result.text) catch false) + result.text + else + std.json.Stringify.valueAlloc(arena, result.text, .{}) catch return ToolError.OutOfMemory; + bridgeStoreSet(app_allocator, &session.bridge_store, name, json_value) catch |err| switch (err) { error.OutOfMemory => return ToolError.OutOfMemory, - error.InvalidJson => return .{ - .text = "save= requires the eval to return JSON; wrap with JSON.stringify(...)", - .is_error = true, - }, + error.InvalidJson => unreachable, }; result = .{ .text = "" }; } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 93e49509..89104fd9 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -738,6 +738,43 @@ test "MCP - eval: save= value is readable via lp. in next eval" { } }, out.written()); } +test "MCP - eval: save= a bare string round-trips without JSON.stringify" { + defer testing.reset(); + var out: std.io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage("about:blank", &out.writer); + defer server.deinit(); + + const save_msg = + \\{ + \\ "jsonrpc": "2.0", + \\ "id": 1, + \\ "method": "tools/call", + \\ "params": { + \\ "name": "eval", + \\ "arguments": { "script": "return document.title || 'untitled';", "save": "title" } + \\ } + \\} + ; + try router.handleMessage(server, testing.arena_allocator, save_msg); + + out.clearRetainingCapacity(); + const read_msg = + \\{ + \\ "jsonrpc": "2.0", + \\ "id": 2, + \\ "method": "tools/call", + \\ "params": { + \\ "name": "eval", + \\ "arguments": { "script": "lp.title" } + \\ } + \\} + ; + try router.handleMessage(server, testing.arena_allocator, read_msg); + try testing.expectJson(.{ .id = 2, .result = .{ + .content = &.{.{ .type = "text", .text = "untitled" }}, + } }, out.written()); +} + test "MCP - eval: lp.* mutations auto-sync between evals" { defer testing.reset(); var out: std.io.Writer.Allocating = .init(testing.arena_allocator);