summaryrefslogtreecommitdiff
path: root/src/debug_log.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-13 23:45:59 -0600
committert <t@tjp.lol>2026-06-15 15:08:32 -0600
commit02b4c7a35ac0b714bc045d54fb4bb45d1ce4e490 (patch)
tree134196a82dd6fdd55b735be4c0cf38428c85038d /src/debug_log.zig
parent71643a5d69ffc40882c9fcde3cc8a3bcf02d7396 (diff)
Add Codex Responses support and session debugging
Teach provider config and auth resolution about the Codex Responses dialect, including user-facing `style = "openai_responses"` with `dialect = "codex"`. Serialize and parse Responses traffic with provider-specific reasoning replay, assistant phase metadata, and robust function-call assembly keyed by `output_index` so streamed tool inputs survive proxy quirks and empty terminal payloads. Also persist thinking origins and message metadata across sessions, add the Anthropic interleaved-thinking header switch, write per-session debug logs, and improve the TUI and scripts for inspecting tool output and session costs.
Diffstat (limited to 'src/debug_log.zig')
-rw-r--r--src/debug_log.zig220
1 files changed, 220 insertions, 0 deletions
diff --git a/src/debug_log.zig b/src/debug_log.zig
new file mode 100644
index 0000000..0adbe63
--- /dev/null
+++ b/src/debug_log.zig
@@ -0,0 +1,220 @@
+//! Debug-build log redirection.
+//!
+//! Debug builds emit a *lot* of `std.log.debug` traffic — most usefully the
+//! raw JSON of every provider API request and response. Writing that to stderr
+//! makes the interactive TUI unusable (every line scribbles over the frame).
+//!
+//! This module installs a custom `std.Options.logFn` (wired up via
+//! `std_options` in `main.zig`). In **Debug** builds it routes the entire log
+//! stream to a per-session file under
+//!
+//! ($PANTO_HOME | $XDG_DATA_HOME/panto | ~/.local/share/panto)/debug/<session-id>.log
+//!
+//! and writes *nothing* to the terminal, leaving the TUI pristine. In any
+//! other build mode it delegates to `std.log.defaultLog` (stderr, level-gated)
+//! so release behavior is unchanged.
+//!
+//! The file is opened with `O_TRUNC`, so each session starts fresh; the file
+//! holds exactly one session's logs and never grows across runs. (A *new*
+//! session id per invocation already gives a fresh file; truncation guards the
+//! resume case where the same id is reused.)
+//!
+//! The log function may be called from any thread (the `Io.Threaded` worker
+//! pool as well as the main loop), so all writes are serialized behind a
+//! mutex. Writes go straight to the fd via `std.c.write` — no `Io` handle is
+//! needed (and none is available at a `logFn` call site), matching the raw-fd
+//! approach already used by `tui_terminal.zig`.
+
+const std = @import("std");
+const builtin = @import("builtin");
+const Io = std.Io;
+const Allocator = std.mem.Allocator;
+
+const panto_home = @import("panto_home.zig");
+
+/// True only in Debug builds; gates the file-redirect path at comptime.
+const enabled = builtin.mode == .Debug;
+
+/// A tiny atomic spinlock serializing writes. `logFn` can be called from any
+/// thread but has no `Io` handle, so `Io.Mutex` (which needs one) is out;
+/// contention is rare and each critical section is a single bounded write.
+var log_lock = std.atomic.Value(bool).init(false);
+
+fn lockLog() void {
+ while (log_lock.cmpxchgWeak(false, true, .acquire, .monotonic) != null) {
+ std.atomic.spinLoopHint();
+ }
+}
+
+fn unlockLog() void {
+ log_lock.store(false, .release);
+}
+/// The open log file descriptor, or -1 before `init` (or if init failed).
+var log_fd: std.c.fd_t = -1;
+/// Monotonic per-line counter. Wall-clock time needs an `Io` handle (not
+/// available at a `logFn` call site), so lines are stamped with a sequence
+/// number instead — enough to order events within the session.
+var seq: u64 = 0;
+/// Buffer backing the per-call `Writer`. Guarded by `log_mutex`.
+var writer_buf: [16 * 1024]u8 = undefined;
+
+/// Open the per-session debug log file and arm `logFn` to write to it. No-op
+/// in non-Debug builds. Best-effort: any failure leaves `log_fd == -1`, and
+/// `logFn` silently drops messages (debug logging must never break startup).
+///
+/// Safe to call once, after the session id is known. `environ_map` is used to
+/// resolve `$PANTO_HOME`; `io` is used only to create the `debug/` directory.
+pub fn init(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ session_id: []const u8,
+) void {
+ if (!enabled) return;
+
+ var layout = panto_home.resolve(allocator, environ_map) catch return;
+ defer layout.deinit();
+
+ const debug_dir = std.fs.path.join(allocator, &.{ layout.home, "debug" }) catch return;
+ defer allocator.free(debug_dir);
+
+ Io.Dir.cwd().createDirPath(io, debug_dir) catch |err| switch (err) {
+ error.PathAlreadyExists => {},
+ else => return,
+ };
+
+ const file_name = std.fmt.allocPrint(allocator, "{s}.log", .{session_id}) catch return;
+ defer allocator.free(file_name);
+ const path = std.fs.path.joinZ(allocator, &.{ debug_dir, file_name }) catch return;
+ defer allocator.free(path);
+
+ const flags = std.c.O{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true };
+ const fd = std.c.open(path.ptr, flags, @as(std.c.mode_t, 0o600));
+ if (fd < 0) return;
+
+ lockLog();
+ defer unlockLog();
+ log_fd = fd;
+ seq = 0;
+}
+
+/// Custom `std.Options.logFn`. Debug builds capture everything to the session
+/// file (terminal stays clean); other builds use the stderr default.
+pub fn logFn(
+ comptime level: std.log.Level,
+ comptime scope: @EnumLiteral(),
+ comptime format: []const u8,
+ args: anytype,
+) void {
+ if (!enabled) {
+ std.log.defaultLog(level, scope, format, args);
+ return;
+ }
+
+ lockLog();
+ defer unlockLog();
+
+ const fd = log_fd;
+ // Before the file is open (early bootstrap), drop the message rather than
+ // letting it reach the terminal — keeping the TUI pristine is the point.
+ if (fd < 0) return;
+
+ var fw: FdWriter = .{
+ .fd = fd,
+ .interface = .{ .vtable = &fd_vtable, .buffer = &writer_buf },
+ };
+ const w = &fw.interface;
+
+ seq += 1;
+ w.print("#{d:>6} {s}", .{ seq, level.asText() }) catch {};
+ if (scope != .default) w.print("({t})", .{scope}) catch {};
+ w.writeAll(": ") catch {};
+ w.print(format, args) catch {};
+ w.writeAll("\n") catch {};
+ w.flush() catch {};
+}
+
+/// A minimal `std.Io.Writer` whose sink is a raw file descriptor. Writes are
+/// best-effort (logging must never fault): short writes are looped, `EINTR` is
+/// retried, and any other error silently drops the remaining bytes.
+const FdWriter = struct {
+ fd: std.c.fd_t,
+ interface: std.Io.Writer,
+};
+
+const fd_vtable: std.Io.Writer.VTable = .{ .drain = drainFd };
+
+fn drainFd(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
+ const self: *FdWriter = @alignCast(@fieldParentPtr("interface", w));
+ const fd = self.fd;
+
+ // 1. Flush any buffered bytes first (they were logically written already).
+ if (w.end != 0) {
+ writeAllFd(fd, w.buffer[0..w.end]);
+ w.end = 0;
+ }
+
+ // 2. Write each data slice in order; the last is repeated `splat` times.
+ if (data.len == 0) return 0;
+ var consumed: usize = 0;
+ for (data[0 .. data.len - 1]) |slice| {
+ writeAllFd(fd, slice);
+ consumed += slice.len;
+ }
+ const last = data[data.len - 1];
+ var i: usize = 0;
+ while (i < splat) : (i += 1) {
+ writeAllFd(fd, last);
+ consumed += last.len;
+ }
+ return consumed;
+}
+
+fn writeAllFd(fd: std.c.fd_t, bytes: []const u8) void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = std.c.write(fd, bytes.ptr + off, bytes.len - off);
+ if (n < 0) {
+ if (std.posix.errno(n) == .INTR) continue;
+ return; // best-effort: drop the rest
+ }
+ if (n == 0) return;
+ off += @intCast(n);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const testing = std.testing;
+
+test "FdWriter: buffered + splat writes reach the fd intact" {
+ const path = "/tmp/panto_debug_log_drain_test.txt";
+ const wr_flags = std.c.O{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true };
+ const fd = std.c.open(path, wr_flags, @as(std.c.mode_t, 0o600));
+ try testing.expect(fd >= 0);
+
+ {
+ // A deliberately tiny buffer forces several drains mid-message,
+ // exercising the buffer-flush path and the splat repetition.
+ var buf: [8]u8 = undefined;
+ var fw: FdWriter = .{
+ .fd = fd,
+ .interface = .{ .vtable = &fd_vtable, .buffer = &buf },
+ };
+ const w = &fw.interface;
+ try w.print("hello {d} world {s}", .{ 42, "xyz" });
+ try w.splatBytesAll("ab", 3); // splat: "ababab"
+ try w.flush();
+ }
+ _ = std.c.close(fd);
+
+ const rfd = std.c.open(path, std.c.O{ .ACCMODE = .RDONLY }, @as(std.c.mode_t, 0));
+ try testing.expect(rfd >= 0);
+ defer _ = std.c.close(rfd);
+ var rbuf: [128]u8 = undefined;
+ const n = std.c.read(rfd, &rbuf, rbuf.len);
+ try testing.expect(n > 0);
+ try testing.expectEqualStrings("hello 42 world xyzababab", rbuf[0..@intCast(n)]);
+}