From b751e500714dc976a4dfa80d2d61496a73560ce3 Mon Sep 17 00:00:00 2001 From: Pranit Date: Thu, 3 Sep 2026 19:27:10 -0400 Subject: [PATCH] Keep Shell correction identity stable across key order Write nested shell fields in their typed order so equivalent rejected requests retain the same correction identity. Preserve omitted fields and cover reordered nested requests in the existing batch regression. --- src/tools/shell/shell.zig | 41 +++++++++++++++++++--- tests/e2e/gateway-stream-lifecycle.test.ts | 8 ++++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/tools/shell/shell.zig b/src/tools/shell/shell.zig index 0c28d5166..ebe510c52 100644 --- a/src/tools/shell/shell.zig +++ b/src/tools/shell/shell.zig @@ -291,7 +291,17 @@ fn request_correction(alloc: Allocator, args_json: []const u8, supports_tty: boo } } } - if (std.json.parseFromValueLeaky(field.type, arena, value, .{})) |_| {} else |err| { + if (std.json.parseFromValueLeaky(field.type, arena, value, .{})) |_| { + if (comptime T == ShellInput) { + var shell: std.json.ObjectMap = .empty; + inline for (@typeInfo(ShellInput).@"struct".fields) |member| { + if (value.object.get(member.name)) |supplied| { + try shell.put(arena, member.name, supplied); + } + } + value = .{ .object = shell }; + } + } else |err| { if (err == error.OutOfMemory) return error.OutOfMemory; if (!type_reported) try problems.append(arena, "request." ++ field.name ++ " must be " ++ expected ++ "."); repairable = false; @@ -1838,8 +1848,8 @@ test "shell request correction suggests only unambiguous repairs without executi } } -fn check_request_correction_allocations(alloc: Allocator) !void { - const result = try decode(.{ .allocator = alloc }, "{\"request\":{\"command\":\"true\"},\"yield_time_ms\":\"30000\"}"); +fn check_request_correction_allocations(alloc: Allocator, args_json: []const u8) !void { + const result = try decode(.{ .allocator = alloc }, args_json); switch (result) { .failure => |failure| alloc.free(failure), .input => |input| { @@ -1850,7 +1860,30 @@ fn check_request_correction_allocations(alloc: Allocator) !void { } test "shell request correction releases partial allocations" { - try std.testing.checkAllAllocationFailures(std.testing.allocator, check_request_correction_allocations, .{}); + try std.testing.checkAllAllocationFailures(std.testing.allocator, check_request_correction_allocations, .{ + "{\"request\":{\"command\":\"true\"},\"yield_time_ms\":\"30000\"}", + }); + try std.testing.checkAllAllocationFailures(std.testing.allocator, check_request_correction_allocations, .{ + "{\"command\":\"true\",\"tty\":true,\"shell\":{\"path\":\"/bin/bash\",\"kind\":\"executable\"}}", + }); +} + +test "shell request correction canonicalizes nested shell members" { + const alloc = std.testing.allocator; + const first = try request_correction(alloc, + \\{"command":"true","tty":true,"shell":{"kind":"executable","path":"/bin/bash","clean_start":false}} + , true); + defer alloc.free(first); + const reordered = try request_correction(alloc, + \\{"shell":{"clean_start":false,"path":"/bin/bash","kind":"executable"},"tty":true,"command":"true"} + , true); + defer alloc.free(reordered); + try std.testing.expectEqualStrings(first, reordered); + const omitted = try request_correction(alloc, + \\{"command":"true","tty":true,"shell":{"path":"/bin/bash","kind":"executable"}} + , true); + defer alloc.free(omitted); + try std.testing.expect(std.mem.find(u8, omitted, "clean_start") == null); } test "shell request correction bounds feedback and preserves input bytes" { diff --git a/tests/e2e/gateway-stream-lifecycle.test.ts b/tests/e2e/gateway-stream-lifecycle.test.ts index df956f749..aeb32e827 100644 --- a/tests/e2e/gateway-stream-lifecycle.test.ts +++ b/tests/e2e/gateway-stream-lifecycle.test.ts @@ -3455,7 +3455,13 @@ describe("gateway stream lifecycle", () => { } return fakeGatewaySse([ { type: "tool-call", toolCallId: `invalid_${batch}`, toolName: "shell", input: { - request: { command: "printf unexpected > must-not-run.txt", profile: "clean" }, + request: { + command: "printf unexpected > must-not-run.txt", + tty: true, + shell: batch === 1 + ? { kind: "executable", path: "/bin/bash" } + : { path: "/bin/bash", kind: "executable" }, + }, yield_time_ms: "1000", } }, { type: "tool-call", toolCallId: `neighbor_${batch}`, toolName: "read_file", input: { path: "neighbor.txt" } },