diff --git a/src/runtime/dispatch.zig b/src/runtime/dispatch.zig index 84dbe3d..cb8d234 100644 --- a/src/runtime/dispatch.zig +++ b/src/runtime/dispatch.zig @@ -15,16 +15,22 @@ const ResolvedAgent = types.ResolvedAgent; /// Dispatch an agent run to the appropriate backend. /// Writes the agent's text output to `out`. +/// If all backends fail to produce output, writes an error message to `out` +/// so the MCP caller knows the dispatch did not succeed. pub fn dispatch( alloc: std.mem.Allocator, resolved: ResolvedAgent, prompt: []const u8, out: *std.ArrayList(u8), ) void { + const before = out.items.len; switch (resolved.backend) { .claude => spawnClaude(alloc, resolved, prompt, out), .codex => spawnCodex(alloc, resolved, prompt, out), } + if (out.items.len == before) { + out.appendSlice(alloc, "[dispatch] backend produced no output — agent run may have failed") catch {}; + } } // ── Claude backend ──────────────────────────────────────────────────────── @@ -50,10 +56,12 @@ fn spawnClaude( .cwd = resolved.cwd, }; - const full_prompt = if (resolved.system_prompt.len > 0) - std.fmt.allocPrint(alloc, "{s}{s}", .{ resolved.system_prompt, prompt }) catch prompt - else - prompt; + const full_prompt = if (resolved.system_prompt.len > 0) blk: { + break :blk std.fmt.allocPrint(alloc, "{s}{s}", .{ resolved.system_prompt, prompt }) catch { + std.debug.print("[dispatch] OOM: system prompt dropped for claude backend\n", .{}); + break :blk prompt; + }; + } else prompt; defer if (full_prompt.ptr != prompt.ptr) alloc.free(full_prompt); if (sdk.tryClaudeAgent(alloc, full_prompt, opts, out)) return; @@ -74,11 +82,12 @@ fn spawnCodex( const policy: cas.SandboxPolicy = if (resolved.writable) .writable else .read_only; - // Prepend system prompt to the user's prompt - const full_prompt = if (resolved.system_prompt.len > 0) - std.fmt.allocPrint(alloc, "{s}{s}", .{ resolved.system_prompt, prompt }) catch prompt - else - prompt; + const full_prompt = if (resolved.system_prompt.len > 0) blk: { + break :blk std.fmt.allocPrint(alloc, "{s}{s}", .{ resolved.system_prompt, prompt }) catch { + std.debug.print("[dispatch] OOM: system prompt dropped for codex backend\n", .{}); + break :blk prompt; + }; + } else prompt; defer if (full_prompt.ptr != prompt.ptr) alloc.free(full_prompt); cas.runTurnPolicy(alloc, full_prompt, out, policy); @@ -93,3 +102,31 @@ test "dispatch: Backend.label is correct" { try std.testing.expectEqualStrings("claude", Backend.claude.label()); try std.testing.expectEqualStrings("codex", Backend.codex.label()); } + +test "dispatch: empty output sentinel is written when backend produces nothing" { + const sentinel = "[dispatch] backend produced no output — agent run may have failed"; + var out: std.ArrayList(u8) = .empty; + const alloc = std.testing.allocator; + defer out.deinit(alloc); + + const before = out.items.len; + // Simulate the check that dispatch() does after a backend call: + if (out.items.len == before) { + out.appendSlice(alloc, sentinel) catch {}; + } + try std.testing.expectEqualStrings(sentinel, out.items); +} + +test "dispatch: non-empty output does not get sentinel appended" { + const alloc = std.testing.allocator; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(alloc); + + try out.appendSlice(alloc, "agent reply"); + const before = out.items.len; + // Simulate: backend already wrote output + if (out.items.len == before) { + out.appendSlice(alloc, "SHOULD NOT APPEAR") catch {}; + } + try std.testing.expectEqualStrings("agent reply", out.items); +}