Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/session_run.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
1 change: 1 addition & 0 deletions src/test_hooks.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 27 additions & 2 deletions src/tool_pulse.zig
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,28 @@ 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;
if (engine_sink.hostedSink()) |sink| {
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));
Expand Down Expand Up @@ -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());
}
4 changes: 2 additions & 2 deletions src/turn_chrome.zig
Original file line number Diff line number Diff line change
@@ -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;
Expand Down