diff --git a/src/session_run.zig b/src/session_run.zig index 32635c38..8940f542 100644 --- a/src/session_run.zig +++ b/src/session_run.zig @@ -141,6 +141,11 @@ pub fn runOneshotPrompt(gpa: Allocator, io: Io, arena: Allocator, root: *agent_m ); var oneshot_user = if (goal_note.len > 0) try std.fmt.allocPrint(arena, "{s}\n\n{s}", .{ ultracode_msg.text, goal_note }) else ultracode_msg.text; if (eval_note.len > 0) oneshot_user = try std.fmt.allocPrint(arena, "{s}\n\n{s}", .{ oneshot_user, eval_note }); + // ADR 0001: the schema grammar stays off the agentic turn. This line only + // stops "Fill the schema" from being read as "find a schema file" — rematch + // 2026-08-28 `schema-output` spent two bash/API rounds inside `.graff/`. + if (root.output_schema != null) + oneshot_user = try std.fmt.allocPrint(arena, "{s}\n\n{s}", .{ oneshot_user, output_schema_agentic_note }); try root.messages.append(try messages_mod.textMessage(arena, "user", oneshot_user)); if (telemetry.g_telem) |t| t.beginTurn(@intCast(@min(prompt_text.len, std.math.maxInt(u32))), root.provider.model); // #502: --output-schema runs TWO-PHASE. A strict grammar on every message @@ -529,6 +534,12 @@ pub fn startBackgroundLearning(gpa: Allocator, arena: Allocator, io: Io, environ } } +/// Rides the agentic user message when `--output-schema` is set. Not the +/// schema itself (ADR 0001) — only "do not go looking for a file". +const output_schema_agentic_note = + \\A later formatting step will apply --output-schema to your final answer. Do not search the workspace for a schema file, and do not open .graff/. +; + /// `--output-schema` checks `json.load` the printed answer. Models that ignore /// json_schema often wrap the object in a markdown fence; strip that only. fn unwrapFencedJson(text: []const u8) []const u8 { @@ -545,3 +556,9 @@ test "unwrapFencedJson strips a markdown fence and leaves bare JSON alone" { try std.testing.expectEqualStrings("{\"a\":1}", unwrapFencedJson("```json\n{\"a\":1}\n```\n")); try std.testing.expectEqualStrings("{\"a\":1}", unwrapFencedJson("{\"a\":1}")); } + +test "output-schema agentic note names the later step and forbids .graff/" { + try std.testing.expect(std.mem.indexOf(u8, output_schema_agentic_note, "--output-schema") != null); + try std.testing.expect(std.mem.indexOf(u8, output_schema_agentic_note, ".graff/") != null); + try std.testing.expect(std.mem.indexOf(u8, output_schema_agentic_note, "{") == null); // no schema grammar +} diff --git a/src/test_hooks.zig b/src/test_hooks.zig index a112a9e1..2aa600d7 100644 --- a/src/test_hooks.zig +++ b/src/test_hooks.zig @@ -327,6 +327,7 @@ test { _ = sandbox_tests; _ = provider_tests; _ = turn_chrome; + _ = @import("tool_pulse.zig"); // -p / --json must not print turn chrome _ = tool_surface; _ = agent_catalog; _ = session_connect_tests; diff --git a/src/tool_pulse.zig b/src/tool_pulse.zig index 9a4e274f..41a9d7e6 100644 --- a/src/tool_pulse.zig +++ b/src/tool_pulse.zig @@ -56,7 +56,10 @@ pub fn formatElapsed(buf: []u8, ms: u64) []const u8 { /// the line REPL/headless keep the process-default sink on the turn thread, /// which a pool-thread tool cannot reach — so chrome lands straight on stdout, /// the same privilege exec_bash_stream's live chunks (and tick_gate's cards) -/// already hold. Presentation pulse: --json drops it. +/// already hold. Presentation pulse: --json and -p drop it (ADR 0020: chrome, +/// not output). `-p` sets `unattended` and promises stdout is only the answer; +/// rematch 2026-08-28 `schema-output` failed `json.load` because +/// `· turn still going ·` rode `g_out` ahead of the object. pub fn emitNotice(io: Io, comptime fmt: []const u8, args: anytype) void { var buf: [160]u8 = undefined; const text = std.fmt.bufPrint(&buf, fmt, args) catch return; @@ -64,12 +67,17 @@ pub fn emitNotice(io: Io, comptime fmt: []const u8, args: anytype) void { sink.emit(io, .{ .session_notice = .{ .text = text, .tone = .dim } }); return; } - if (main_mod.json_mode) return; + if (!chromeGoesToStdout()) return; const w = main_mod.g_out orelse return; w.print("{s}{s}{s}\n", .{ style.dim, text, style.reset }) catch return; w.flush() catch {}; } +/// Line-REPL only. Hosted TUI/ACP still get the pulse via `hostedSink`. +pub fn chromeGoesToStdout() bool { + return !main_mod.json_mode and !main_mod.unattended; +} + test "Pulse fires once per silence threshold, then on the interval" { var p: Pulse = .{}; try std.testing.expect(!p.due(0)); @@ -99,3 +107,20 @@ test "formatElapsed renders seconds, minutes, and hours" { try std.testing.expectEqualStrings("2m10s", formatElapsed(&buf, 130_000)); try std.testing.expectEqualStrings("1h00m", formatElapsed(&buf, 3_600_000)); } + +test "chrome does not ride --json or -p stdout" { + const prev_u = main_mod.unattended; + const prev_j = main_mod.json_mode; + defer { + main_mod.unattended = prev_u; + main_mod.json_mode = prev_j; + } + main_mod.unattended = false; + main_mod.json_mode = false; + try std.testing.expect(chromeGoesToStdout()); + main_mod.unattended = true; + try std.testing.expect(!chromeGoesToStdout()); + main_mod.unattended = false; + main_mod.json_mode = true; + try std.testing.expect(!chromeGoesToStdout()); +} diff --git a/src/turn_chrome.zig b/src/turn_chrome.zig index db90078d..7d7a5e4d 100644 --- a/src/turn_chrome.zig +++ b/src/turn_chrome.zig @@ -1,8 +1,8 @@ //! Dim chrome for long inner-loop turns and API transport retries (retry n/N). //! //! Production default: unlimited inner-loop model calls (`max_turn_model_calls = 0`). -//! `GRAFF_MAX_TURN_MODEL_CALLS` is the opt-in cap. JSON mode is dropped inside -//! `tool_pulse.emitNotice` (ADR 0020: chrome, not output). +//! `GRAFF_MAX_TURN_MODEL_CALLS` is the opt-in cap. `--json` and `-p` drop the +//! pulse inside `tool_pulse.emitNotice` (ADR 0020: chrome, not output). const std = @import("std"); const Io = std.Io;