Problem
handleBundle in src/mcp.zig has a 200KB output cap. When appending sub_out would exceed that cap, the handler emits a TRUNCATED entry for the offending op and then breaks out of the for-loop — silently abandoning every op after that index.
Concretely, a bundle of three reads — two large, one tiny — emits headers for [0] and [1], then breaks. Op [2], which the caller submitted, gets no header, no error, no acknowledgement that it was even received. The wire response is indistinguishable from a 2-op bundle. Any client that tries to correlate ops to results by index is going to mis-attribute output.
Failing Test
Branch: issue-413-failing-test
test "issue-413: bundle truncation drops subsequent ops without telling the caller" {
var explorer = Explorer.init(testing.allocator);
defer explorer.deinit();
var store = Store.init(testing.allocator);
defer store.deinit();
var agents = AgentRegistry.init(testing.allocator);
defer agents.deinit();
_ = try agents.register("__filesystem__");
var bench_ctx = mcp_mod.BenchContext.init(testing.allocator, ".");
defer bench_ctx.deinit();
var big: std.ArrayList(u8) = .empty;
defer big.deinit(testing.allocator);
while (big.items.len < 120 * 1024) {
try big.appendSlice(testing.allocator, "pub fn placeholder() void { _ = 0; }\n");
}
try explorer.indexFile("big.zig", big.items);
try explorer.indexFile("small.zig", "pub fn small() void {}\n");
const bundle_json =
\\{"ops":[
\\ {"tool":"codedb_read","arguments":{"path":"big.zig"}},
\\ {"tool":"codedb_read","arguments":{"path":"big.zig"}},
\\ {"tool":"codedb_outline","arguments":{"path":"small.zig"}}
\\]}
;
const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, bundle_json, .{});
defer parsed.deinit();
var out: std.ArrayList(u8) = .empty;
defer out.deinit(testing.allocator);
bench_ctx.runDispatch(io, testing.allocator, .codedb_bundle, &parsed.value.object, &out, &store, &explorer, &agents);
// op[2] (index 2) was sent — caller deserves to see something for it.
try testing.expect(std.mem.indexOf(u8, out.items, "[2]") != null);
}
Expected
When truncation kicks in, the bundle response should still emit a --- [N] tool --- header for every remaining op, marked as DROPPED (or similar) so the caller can distinguish "ran but errored" from "never ran because we hit the cap".
Fix
In handleBundle (src/mcp.zig ~line 1764), after the truncation break, run a second pass that emits a placeholder entry for each remaining op:
if (out.items.len + sub_out.items.len > 200 * 1024) {
w.print("--- [{d}] {s} ---\nTRUNCATED: ...\n", .{ i, tool_name }) catch {};
fail_count += 1;
// Still acknowledge the dropped ops so the caller can correlate.
for (ops[i + 1 ..], i + 1..) |dropped_op, j| {
const dt = if (dropped_op == .object) (getStr(&dropped_op.object, "tool") orelse "?") else "?";
w.print("--- [{d}] {s} ---\nDROPPED: bundle truncated before this op ran\n", .{ j, dt }) catch {};
fail_count += 1;
}
break;
}
Problem
handleBundlein src/mcp.zig has a 200KB output cap. When appendingsub_outwould exceed that cap, the handler emits a TRUNCATED entry for the offending op and thenbreaks out of the for-loop — silently abandoning every op after that index.Concretely, a bundle of three reads — two large, one tiny — emits headers for
[0]and[1], then breaks. Op[2], which the caller submitted, gets no header, no error, no acknowledgement that it was even received. The wire response is indistinguishable from a 2-op bundle. Any client that tries to correlate ops to results by index is going to mis-attribute output.Failing Test
Branch:
issue-413-failing-testExpected
When truncation kicks in, the bundle response should still emit a
--- [N] tool ---header for every remaining op, marked as DROPPED (or similar) so the caller can distinguish "ran but errored" from "never ran because we hit the cap".Fix
In
handleBundle(src/mcp.zig ~line 1764), after the truncationbreak, run a second pass that emits a placeholder entry for each remaining op: