diff options
| author | t <t@tjp.lol> | 2026-06-13 23:45:59 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-15 15:08:32 -0600 |
| commit | 02b4c7a35ac0b714bc045d54fb4bb45d1ce4e490 (patch) | |
| tree | 134196a82dd6fdd55b735be4c0cf38428c85038d /src | |
| parent | 71643a5d69ffc40882c9fcde3cc8a3bcf02d7396 (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')
| -rw-r--r-- | src/auth_manager.zig | 5 | ||||
| -rw-r--r-- | src/config_file.zig | 69 | ||||
| -rw-r--r-- | src/debug_log.zig | 220 | ||||
| -rw-r--r-- | src/lua_runtime.zig | 140 | ||||
| -rw-r--r-- | src/luarocks_runtime.zig | 103 | ||||
| -rw-r--r-- | src/main.zig | 17 | ||||
| -rw-r--r-- | src/models_toml.zig | 40 | ||||
| -rw-r--r-- | src/tui_app.zig | 7 | ||||
| -rw-r--r-- | src/tui_components.zig | 167 | ||||
| -rw-r--r-- | src/tui_selectors.zig | 10 |
10 files changed, 719 insertions, 59 deletions
diff --git a/src/auth_manager.zig b/src/auth_manager.zig index e26e28a..5504807 100644 --- a/src/auth_manager.zig +++ b/src/auth_manager.zig @@ -198,6 +198,11 @@ fn patchCredential( c.base_url = base_url; c.extra_headers = merged; }, + .openai_codex_responses => |*c| { + c.api_key = cred.api_key; + c.base_url = base_url; + c.extra_headers = merged; + }, } } diff --git a/src/config_file.zig b/src/config_file.zig index 1823177..8f6725c 100644 --- a/src/config_file.zig +++ b/src/config_file.zig @@ -77,6 +77,10 @@ pub const Provider = struct { /// the name used on the left of a `<provider>:<model>` reference. name: []const u8, style: APIStyle, + /// User-facing sub-dialect for styles that share a protocol family. + /// Currently only `style = "openai_responses"` supports `dialect = "codex"`, + /// which maps to internal `APIStyle.openai_codex_responses`. + dialect: ?[]const u8 = null, base_url: []const u8, /// The `auth.<name>` session this provider draws its credential from. auth_name: []const u8, @@ -90,6 +94,7 @@ pub const Provider = struct { pub fn deinit(self: Provider, alloc: Allocator) void { alloc.free(self.name); + if (self.dialect) |d| alloc.free(d); alloc.free(self.base_url); alloc.free(self.auth_name); // `extra_headers` lives in the Config auth arena; not freed here. @@ -213,6 +218,14 @@ pub fn buildProviderConfig( .max_tokens = max_tokens, .extra_headers = prov.extra_headers, } }, + .openai_codex_responses => return .{ .openai_codex_responses = .{ + .api_key = api_key, + .base_url = prov.base_url, + .model = wire_model, + .reasoning = reasoning, + .max_tokens = max_tokens, + .extra_headers = prov.extra_headers, + } }, } } @@ -677,7 +690,16 @@ fn resolveProvider( const style_str = (val.get("style") orelse return error.InvalidProvider).asString() orelse return error.InvalidProvider; - const style = std.meta.stringToEnum(APIStyle, style_str) orelse return error.InvalidProvider; + var style = std.meta.stringToEnum(APIStyle, style_str) orelse return error.InvalidProvider; + if (style == .openai_codex_responses) return error.InvalidProvider; + const dialect = if (tomlStr(val, "dialect")) |d| d else null; + if (dialect) |d| { + if (std.mem.eql(u8, style_str, "openai_responses") and std.mem.eql(u8, d, "codex")) { + style = .openai_codex_responses; + } else { + return error.InvalidProvider; + } + } const base_url = (val.get("base_url") orelse return error.InvalidProvider).asString() orelse return error.InvalidProvider; @@ -699,6 +721,7 @@ fn resolveProvider( return .{ .name = try allocator.dupe(u8, name), .style = style, + .dialect = if (dialect) |d| try allocator.dupe(u8, d) else null, .base_url = try allocator.dupe(u8, base_url), .auth_name = try allocator.dupe(u8, auth_name), .prompt_cache = prompt_cache, @@ -1231,6 +1254,50 @@ test "resolve: provider extra_headers flow into the built provider config" { try testing.expectEqualStrings("tok", pc.openai_chat.api_key); } +test "resolve: openai_responses codex dialect maps to internal API style" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + const src = + \\[providers.codex] + \\style = "openai_responses" + \\dialect = "codex" + \\base_url = "https://chatgpt.com/backend-api" + \\auth = "k" + \\ + \\[auth.k] + \\key = "tok" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + + try testing.expectEqual(APIStyle.openai_codex_responses, cfg.provider("codex").?.style); + var defs = models_toml.ModelRegistry.init(a); + defer defs.deinit(); + const pc = try buildProviderConfig(&cfg, &defs, .{ .provider = "codex", .model = "gpt-5.5" }); + try testing.expectEqual(APIStyle.openai_codex_responses, pc.style()); +} + +test "resolve: internal openai_codex_responses style is not user-facing" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + const src = + \\[providers.codex] + \\style = "openai_codex_responses" + \\base_url = "https://chatgpt.com/backend-api" + \\auth = "k" + \\ + \\[auth.k] + \\key = "tok" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + try testing.expectError(error.InvalidProvider, resolve(a, &env, doc.root)); +} + test "resolve: prompt_cache defaults true and is parsed when set" { const a = testing.allocator; var env = emptyEnv(a); 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)]); +} diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig index 2574fae..35247bb 100644 --- a/src/lua_runtime.zig +++ b/src/lua_runtime.zig @@ -87,6 +87,15 @@ pub const LuaRuntime = struct { /// calls to drive libuv to completion. `0` until /// `installScheduler` runs. uv_run_ref: c_int = 0, + /// Registry ref to `require("luv").update_time`. libuv caches the + /// loop's notion of "now" and only refreshes it while `uv.run` + /// executes; the loop is idle between batches (and during a slow + /// model's streaming), so its clock goes stale. The scheduler calls + /// this at the start of each batch so handlers that arm timers + /// (e.g. the shell tool's timeout) compute deadlines against the + /// real current time, not a frozen one. `0` until + /// `installScheduler` runs. + uv_update_time_ref: c_int = 0, /// Pointer to the in-flight batch, valid only for the duration of /// one `invoke_batch` call. The `panto._record_result` C function @@ -220,6 +229,7 @@ pub const LuaRuntime = struct { try installRecordResult(self); try installWrapperClosure(self); try cacheUvRun(self); + try cacheUvUpdateTime(self); } /// Tear down the runtime: free every owned string, unref every @@ -244,6 +254,9 @@ pub const LuaRuntime = struct { if (self.uv_run_ref != 0) { c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_run_ref); } + if (self.uv_update_time_ref != 0) { + c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_update_time_ref); + } // Free the UI event bridge (Lua handler/component refs + caches) // BEFORE closing the state, since it unrefs registry entries. @@ -724,6 +737,12 @@ fn runBatch( return; } + // Refresh libuv's cached loop clock before any handler arms a timer. + // The loop sits idle between batches (and while a slow model streams), + // so its notion of "now" is stale; a timeout armed against it would + // otherwise fire the instant `uv.run` refreshes time below. + refreshLoopClock(self); + var slots = try allocator.alloc(Slot, calls.len); defer allocator.free(slots); for (slots) |*s| s.* = .{}; @@ -1156,6 +1175,45 @@ fn cacheUvRun(self: *LuaRuntime) !void { self.uv_run_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); } +/// Cache `require("luv").update_time` so the scheduler can refresh the +/// loop clock at the start of each batch. See `uv_update_time_ref`. +fn cacheUvUpdateTime(self: *LuaRuntime) !void { + const L = self.L; + const snippet = + \\local uv = require("luv") + \\return uv.update_time + ; + if (c.luaL_loadstring(L, snippet) != 0) { + logTopAsError(L, "panto-lua: failed to compile luv.update_time lookup"); + c.lua_settop(L, c.lua_gettop(L) - 1); + return RuntimeError.LuaInitFailed; + } + if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) { + logTopAsError(L, "panto-lua: require('luv') failed (was the bootstrap successful?)"); + c.lua_settop(L, c.lua_gettop(L) - 1); + return RuntimeError.LuaInitFailed; + } + if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) { + c.lua_settop(L, c.lua_gettop(L) - 1); + return RuntimeError.LuaInitFailed; + } + self.uv_update_time_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX); +} + +/// Refresh libuv's cached loop clock (`uv.update_time()`). Best-effort: +/// a failure here only means timeout deadlines may be computed against a +/// slightly stale clock, so we log and continue rather than failing the +/// batch. +fn refreshLoopClock(self: *LuaRuntime) void { + if (self.uv_update_time_ref == 0) return; + const L = self.L; + _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(self.uv_update_time_ref)); + if (c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) { + logTopAsError(L, "panto-lua: uv.update_time failed"); + c.lua_settop(L, c.lua_gettop(L) - 1); + } +} + // --------------------------------------------------------------------------- // Internals // --------------------------------------------------------------------------- @@ -1778,6 +1836,88 @@ test "scheduler: two real std.shell calls in one batch do not deadlock" { try testing.expect(std.mem.indexOf(u8, okText(results[1]), "second-done") != null); } +test "scheduler: shell timeout is measured from dispatch, not a stale loop clock" { + // Regression: libuv caches the loop's "now" and only refreshes it while + // `uv.run` runs. The loop is idle between batches, so a tool that arms a + // timeout timer against the stale clock would have it fire the instant + // `uv.run` refreshes time. We force staleness with `uv.sleep` (a blocking + // sleep that does NOT run the loop), then dispatch a 1s-timeout shell call + // and assert it does not spuriously time out. + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + const luarocks_rt = bootstrapRealRuntime(rt) catch |e| return e; + defer luarocks_rt.deinit(); + + const tools_dir = try findAgentToolsDir(); + defer testing.allocator.free(tools_dir); + const shell_path = try std.fs.path.join(testing.allocator, &.{ tools_dir, "shell.lua" }); + defer testing.allocator.free(shell_path); + try rt.loadTool(shell_path, tools_dir); + + var src = rt.toolSource(); + + // Prime the loop clock with one run. + { + const warm = [_]panto.ToolCall{.{ .tool_name = "std.shell", .input = "{\"command\":\"true\"}" }}; + var wres: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; + try src.vtable.invoke_batch(src.ctx, &warm, &wres, testing.allocator); + freeResults(&wres); + } + + // Advance real time ~1.2s WITHOUT running the loop, so its cached clock + // goes stale relative to the wall clock by more than the timeout below. + { + const snippet = "require('luv').sleep(1200)"; + if (c.luaL_loadstring(rt.L, snippet) != 0) return error.SleepSnippetFailed; + if (c.lua_pcallk(rt.L, 0, 0, 0, 0, null) != 0) return error.SleepSnippetFailed; + } + + const calls = [_]panto.ToolCall{ + .{ .tool_name = "std.shell", .input = "{\"command\":\"echo prompt-done\",\"timeout\":1}" }, + }; + var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }}; + try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); + defer freeResults(&results); + + try testing.expect(std.mem.indexOf(u8, okText(results[0]), "prompt-done") != null); + try testing.expect(std.mem.indexOf(u8, okText(results[0]), "timed out") == null); +} + +test "scheduler: three real std.shell calls with explicit timeout all succeed" { + var rt = try LuaRuntime.create(testing.allocator); + defer rt.deinit(); + const luarocks_rt = bootstrapRealRuntime(rt) catch |e| return e; + defer luarocks_rt.deinit(); + + const tools_dir = try findAgentToolsDir(); + defer testing.allocator.free(tools_dir); + const shell_path = try std.fs.path.join(testing.allocator, &.{ tools_dir, "shell.lua" }); + defer testing.allocator.free(shell_path); + try rt.loadTool(shell_path, tools_dir); + + var src = rt.toolSource(); + const calls = [_]panto.ToolCall{ + .{ .tool_name = "std.shell", .input = "{\"command\":\"echo alpha-done\",\"timeout\":30}" }, + .{ .tool_name = "std.shell", .input = "{\"command\":\"echo bravo-done\",\"timeout\":30}" }, + .{ .tool_name = "std.shell", .input = "{\"command\":\"echo charlie-done\",\"timeout\":30}" }, + }; + var results: [3]panto.ToolCallResult = .{ + .{ .err = error.SourceDroppedCall }, + .{ .err = error.SourceDroppedCall }, + .{ .err = error.SourceDroppedCall }, + }; + try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator); + defer freeResults(&results); + + try testing.expect(std.mem.indexOf(u8, okText(results[0]), "alpha-done") != null); + try testing.expect(std.mem.indexOf(u8, okText(results[1]), "bravo-done") != null); + try testing.expect(std.mem.indexOf(u8, okText(results[2]), "charlie-done") != null); + // None should have hit the timeout path. + for (results) |r| { + try testing.expect(std.mem.indexOf(u8, okText(r), "timed out") == null); + } +} + // Reproduction: two REAL `std.read` calls in one batch. read.lua uses // only synchronous `uv.fs_*` calls and never yields, so both should // complete during dispatch without entering the drive loop. diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig index f76a60e..7f2e161 100644 --- a/src/luarocks_runtime.zig +++ b/src/luarocks_runtime.zig @@ -29,6 +29,7 @@ //! `<panto-binary> lua` (with the absolute path of the running `panto` //! binary) into the luarocks config as `variables.LUA`. +const builtin = @import("builtin"); const std = @import("std"); const Allocator = std.mem.Allocator; const Io = std.Io; @@ -70,6 +71,7 @@ pub const BootstrapError = error{ LuarocksInjectionFailed, LuarocksInstallFailed, LuarocksSearcherInstallFailed, + PantoModuleSigningFailed, PathConfigFailed, PantoExecutablePathUnknown, } || Allocator.Error; @@ -229,6 +231,98 @@ fn stagePantoModule( var dir = try Io.Dir.cwd().openDir(io, layout.lib_lua_dir, .{}); defer dir.close(io); try writeIfDifferent(allocator, io, dir, "panto.so", embedded_panto_so.bytes); + try ensurePantoModuleSignature(allocator, layout); +} + +/// Ad-hoc re-sign the staged `panto.so` so macOS will `dlopen` it. +/// +/// We deliberately spawn `codesign` via `posix_spawn` rather than +/// `std.process.run`. `std.process.run` (via `Io.Threaded`) implements +/// spawning as a raw `fork()` + `execve()`. On macOS a `fork()` from a +/// process that has *any* live secondary thread runs the registered +/// `pthread_atfork` child handlers in the forked child; system-framework +/// handlers can dereference per-thread state that no longer exists in the +/// child and fault (`EXC_BAD_ACCESS`). In a normal `panto` run bootstrap +/// is still single-threaded here so the fork is safe, but under the +/// multithreaded Zig test runner (whose pool already has worker threads, +/// and which installs a debug `SIGSEGV` handler that turns that fault into +/// an `abort()`) the spawned child dies with `SIGABRT` before `codesign` +/// ever execs. `posix_spawn` is a single Darwin primitive that does *not* +/// run userspace `pthread_atfork` handlers — Apple's recommended way to +/// spawn from a multithreaded process — so it sidesteps the hazard +/// entirely while behaving identically to the caller. The child's stdio is +/// redirected to /dev/null (see below); failures surface via the exit +/// status, which we translate into `error.PantoModuleSigningFailed`. +fn ensurePantoModuleSignature( + allocator: Allocator, + layout: panto_home.Layout, +) !void { + if (builtin.os.tag != .macos) return; + + const module_path = try std.fs.path.joinZ(allocator, &.{ layout.lib_lua_dir, "panto.so" }); + defer allocator.free(module_path); + + const argv = [_:null]?[*:0]const u8{ + "/usr/bin/codesign", + "--force", + "--sign", + "-", + module_path.ptr, + null, + }; + + // Redirect the child's stdio to /dev/null: `codesign` is silent on + // success and writes progress/errors to stderr, which we don't want + // leaking into panto's own output (and, under the test runner, racing + // with the build-server IPC). Failures are surfaced via the exit + // status and our own `std.log.err` below. + var actions: std.c.posix_spawn_file_actions_t = undefined; + if (std.c.posix_spawn_file_actions_init(&actions) != 0) { + return error.PantoModuleSigningFailed; + } + defer _ = std.c.posix_spawn_file_actions_destroy(&actions); + const rdonly: c_int = @bitCast(std.c.O{ .ACCMODE = .RDONLY }); + const wronly: c_int = @bitCast(std.c.O{ .ACCMODE = .WRONLY }); + _ = std.c.posix_spawn_file_actions_addopen(&actions, std.c.STDIN_FILENO, "/dev/null", rdonly, 0); + _ = std.c.posix_spawn_file_actions_addopen(&actions, std.c.STDOUT_FILENO, "/dev/null", wronly, 0); + _ = std.c.posix_spawn_file_actions_addopen(&actions, std.c.STDERR_FILENO, "/dev/null", wronly, 0); + + var pid: std.c.pid_t = undefined; + const spawn_rc = std.c.posix_spawn( + &pid, + "/usr/bin/codesign", + &actions, + null, + &argv, + @ptrCast(std.c.environ), + ); + if (spawn_rc != 0) { + std.log.err( + "failed to spawn codesign for staged panto module at {s}: posix_spawn errno={d}", + .{ module_path, spawn_rc }, + ); + return error.PantoModuleSigningFailed; + } + + const status = blk: while (true) { + var status: c_int = undefined; + const rc = std.c.waitpid(pid, &status, 0); + if (rc == -1) { + const err = std.posix.errno(rc); + if (err == .INTR) continue; + std.log.err("failed to wait for codesign: errno={t}", .{err}); + return error.PantoModuleSigningFailed; + } + break :blk @as(u32, @bitCast(status)); + }; + + if (std.c.W.IFEXITED(status) and std.c.W.EXITSTATUS(status) == 0) return; + + std.log.err( + "codesign failed for staged panto module at {s}: status=0x{x}", + .{ module_path, status }, + ); + return error.PantoModuleSigningFailed; } // --------------------------------------------------------------------------- @@ -338,14 +432,13 @@ const default_base_config = \\# exchange_expires_path = "expires_at" \\# exchange_base_url_path = "endpoints.api" \\ - \\# OpenAI Codex via a ChatGPT subscription (device login). Codex speaks the - \\# OpenAI Responses API, not Chat Completions, so style = "openai_responses". - \\# NOTE: this path is implemented but not yet verified against a live - \\# ChatGPT plan — see docs/oauth-provider-auth.md. + \\# OpenAI Codex via a ChatGPT subscription (device login). Codex uses the + \\# Responses protocol with a stricter ChatGPT-subscription dialect. \\# \\# [providers.codex] \\# style = "openai_responses" - \\# base_url = "https://chatgpt.com/backend-api/codex" + \\# dialect = "codex" + \\# base_url = "https://chatgpt.com/backend-api" \\# auth = "openai_codex" \\# \\# [providers.codex.extra_headers] diff --git a/src/main.zig b/src/main.zig index 58502ad..8ac5e8b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -16,6 +16,12 @@ const glob = @import("glob.zig"); const system_prompt = @import("system_prompt.zig"); const command = @import("command.zig"); const command_compaction = @import("compaction.zig"); +const debug_log = @import("debug_log.zig"); + +/// Route the process-wide log stream. In Debug builds this redirects every +/// `std.log` call to a per-session file (keeping the TUI clean); other builds +/// keep the stderr default. See `debug_log.zig`. +pub const std_options: std.Options = .{ .logFn = debug_log.logFn }; // TUI foundation layer (Phase 1, sub-phase 1). Not yet wired into the REPL; // referenced here so `zig build test` type-checks and exercises them. @@ -60,6 +66,7 @@ test { _ = system_prompt; _ = command; _ = command_compaction; + _ = debug_log; _ = tui_terminal; _ = tui_key; _ = tui_input; @@ -255,7 +262,9 @@ pub fn main(init: std.process.Init) !void { // per-cwd `session_dir` (the CLI owns the cwd→dir grouping; the store // is cwd-agnostic). Must outlive the agent, which appends through a // `Session` handle minted/resolved below. - const session_metadata = try std.fmt.allocPrint(alloc, "{{\"cwd\":{s}}}", .{try std.json.Stringify.valueAlloc(alloc, cwd, .{})}); + const cwd_json = try std.json.Stringify.valueAlloc(alloc, cwd, .{}); + defer alloc.free(cwd_json); + const session_metadata = try std.fmt.allocPrint(alloc, "{{\"cwd\":{s}}}", .{cwd_json}); defer alloc.free(session_metadata); var session_store_impl = try panto.FileSystemJSONLStore.initWithMetadata(alloc, io, session_dir, session_metadata); defer session_store_impl.deinit(); @@ -277,6 +286,12 @@ pub fn main(init: std.process.Init) !void { // `session.info` is adopted by the agent below (`Agent.init` can't fail) // and freed in the agent's `deinit`; no separate cleanup here. + // Arm the per-session debug log now that we know the session id. In Debug + // builds this opens `$PANTO_HOME/debug/<id>.log` and redirects all + // `std.log` output there; no-op in release. Best-effort — failures leave + // logging disabled rather than aborting startup. + debug_log.init(alloc, io, init.environ_map, session.info.id); + const is_resume = cli_flags.resume_kind != .none and session.info.message_count > 0; // On resume, reconstruct the conversation from the store. (The diff --git a/src/models_toml.zig b/src/models_toml.zig index 955640d..622bdf6 100644 --- a/src/models_toml.zig +++ b/src/models_toml.zig @@ -21,7 +21,8 @@ //! //! # anthropic_messages-only knobs: //! api_version = <string> # Anthropic-Version header; default "2023-06-01" -//! thinking = <string> # disabled | enabled | adaptive (default: disabled) +//! thinking = <string> # disabled | enabled | adaptive (default: disabled, +//! # or adaptive when `effort` is present) //! effort = <string> # low | medium | high | xhigh | max (default: medium) //! # only used when thinking = "adaptive" //! thinking_budget_tokens = <int> # max reasoning tokens for thinking = "enabled" @@ -268,13 +269,15 @@ fn ingestModel( break :blk null; }; + const has_effort = v.get("effort") != null; + const thinking: Thinking = blk: { if (v.get("thinking")) |t| { if (t.asString()) |s| { break :blk std.meta.stringToEnum(Thinking, s) orelse .disabled; } } - break :blk .disabled; + break :blk if (has_effort) .adaptive else .disabled; }; const effort: Effort = blk: { @@ -527,6 +530,39 @@ test "parseInto: anthropic adaptive thinking with effort" { try testing.expectEqual(false, def.thinking_interleaved); // default } +test "parseInto: anthropic effort implies adaptive thinking" { + var models = emptyModels(testing.allocator); + defer models.deinit(); + + const src = + \\[anthropic.opus] + \\model = "claude-opus-4-8" + \\effort = "high" + ; + try parseInto(&models, src); + + const def = models.defs.get("anthropic", "opus").?; + try testing.expectEqual(Thinking.adaptive, def.thinking); + try testing.expectEqual(Effort.high, def.effort); +} + +test "parseInto: explicit thinking overrides effort-implied adaptive thinking" { + var models = emptyModels(testing.allocator); + defer models.deinit(); + + const src = + \\[anthropic.haiku] + \\model = "claude-haiku-4-5-20251001" + \\thinking = "disabled" + \\effort = "high" + ; + try parseInto(&models, src); + + const def = models.defs.get("anthropic", "haiku").?; + try testing.expectEqual(Thinking.disabled, def.thinking); + try testing.expectEqual(Effort.high, def.effort); +} + test "parseInto: anthropic thinking disabled" { var models = emptyModels(testing.allocator); defer models.deinit(); diff --git a/src/tui_app.zig b/src/tui_app.zig index 1182777..69eb8fa 100644 --- a/src/tui_app.zig +++ b/src/tui_app.zig @@ -1402,16 +1402,13 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void { // that here. Otherwise seed the plain model label. if (app.selectors == null) try app.footer.setModel(opts.model_label); - // Session-start welcome banner as the first transcript entry. cwd is read - // from the process; the model label comes from the run options. (Version - // is not threaded through the run options yet; the banner omits it.) + // Session-start welcome banner as the first transcript entry. { const welcome = try app.spawnWelcome(.{ .version = opts.version, .cwd = opts.cwd, .model = opts.model_label, }); - try welcome.setModel(opts.model_label); if (opts.cwd.len != 0) try welcome.setCwd(opts.cwd); if (opts.version.len != 0) try welcome.setVersion(opts.version); } @@ -2371,7 +2368,7 @@ test "spawnWelcome shows a session-start banner entry" { defer h.teardown(alloc); const w = try h.app.spawnWelcome(.{}); - try w.setModel("m"); + _ = w; try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len); try testing.expect(h.app.transcript.items[0].kind == .welcome); } diff --git a/src/tui_components.zig b/src/tui_components.zig index e64b5bd..14c61d9 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -983,9 +983,10 @@ pub const InputBox = struct { // Locate the cursor's (row, byte-col-in-row). const focused = self.focusable.focused; - // Walk lines, tracking byte offset so we know which row holds cursor. - // `cursor_row` records which produced row carries the cursor block, so - // the scroll-window below can keep it visible. + // Walk logical lines, but render HARD-WRAPPED visual rows. Wrapping is + // purely visual: no '\n' bytes are inserted into the editor buffer. + // `cursor_row` records which produced visual row carries the cursor + // block, so the scroll-window below can keep it visible. var line_byte_start: usize = 0; var produced_any = false; var cursor_row: usize = 0; @@ -994,22 +995,19 @@ pub const InputBox = struct { const line_start = line_byte_start; const line_end = line_start + line.len; const cursor_in_line = focused and self.cursor >= line_start and self.cursor <= line_end and - // The cursor belongs to the FIRST line whose range contains it - // (at a '\n' boundary it stays on the line before the break, - // i.e. == line_end). Disambiguate the boundary: if cursor == - // line_end and there are more lines, it belongs to the NEXT - // line's start unless this is the last line. + // At an explicit '\n' boundary, the cursor belongs to the next + // logical line's start unless this is the final line. (self.cursor < line_end or it.peek() == null); - if (cursor_in_line) cursor_row = rows.items.len; - const row = try self.renderRow(line, if (cursor_in_line) self.cursor - line_start else null, cursor_style, width, focused); - try rows.append(a, row); - produced_any = true; + const first_row = rows.items.len; + try self.appendWrappedRows(line, line_start, cursor_in_line, cursor_style, width, focused, &rows, &cursor_row); + produced_any = produced_any or rows.items.len > first_row; line_byte_start = line_end + 1; // skip the '\n' } if (!produced_any) { // Empty buffer: a single (possibly cursor-bearing) row. const row = try self.renderRow("", if (focused) @as(?usize, 0) else null, cursor_style, width, focused); + if (focused) cursor_row = 0; try rows.append(a, row); } @@ -1036,6 +1034,80 @@ pub const InputBox = struct { return cacheLines(&self.cache); } + /// Append the hard-wrapped visual rows for one logical line. The slices are + /// rendered immediately into owned row bytes; the editor buffer itself is + /// unchanged. Cursor placement follows terminal wrapping semantics: a + /// cursor exactly at a wrap boundary appears at column 0 of the next visual + /// row, not at the end of the previous one. + fn appendWrappedRows( + self: *InputBox, + line: []const u8, + line_start: usize, + cursor_in_line: bool, + cursor_style: Style, + width: usize, + focused: bool, + rows: *std.ArrayList([]const u8), + cursor_row: *usize, + ) !void { + const a = self.alloc; + + if (width == 0) { + const row = try self.renderRow("", if (cursor_in_line) @as(?usize, 0) else null, cursor_style, width, focused); + if (cursor_in_line) cursor_row.* = rows.items.len; + try rows.append(a, row); + return; + } + + if (line.len == 0) { + const row = try self.renderRow("", if (cursor_in_line) @as(?usize, 0) else null, cursor_style, width, focused); + if (cursor_in_line) cursor_row.* = rows.items.len; + try rows.append(a, row); + return; + } + + var i: usize = 0; + while (i < line.len) { + const chunk_start = i; + var cols: usize = 0; + while (i < line.len) { + const seq_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1; + const adv = @min(seq_len, line.len - i); + const cp = std.unicode.utf8Decode(line[i .. i + adv]) catch '?'; + const w = codepointWidth(cp); + if (cols > 0 and cols + w > width) break; + i += adv; + cols += w; + if (cols >= width) break; + } + if (i == chunk_start) i = self.nextBoundary(line_start + i) - line_start; // defensive progress + + const chunk = line[chunk_start..i]; + const abs_start = line_start + chunk_start; + const abs_end = line_start + i; + var cursor_col: ?usize = null; + if (cursor_in_line) { + if (self.cursor >= abs_start and self.cursor < abs_end) { + cursor_col = self.cursor - abs_start; + } else if (i == line.len and self.cursor == abs_end) { + cursor_col = chunk.len; + } + } + if (cursor_col != null) cursor_row.* = rows.items.len; + const row = try self.renderRow(chunk, cursor_col, cursor_style, width, focused); + try rows.append(a, row); + } + + // If the cursor is at the end of a line that exactly fills the last + // visual row, show the cursor on the following wrapped row instead of + // replacing the last visible character with the block. + if (cursor_in_line and self.cursor == line_start + line.len and displayWidth(line) % width == 0) { + const row = try self.renderRow("", @as(?usize, 0), cursor_style, width, focused); + cursor_row.* = rows.items.len; + try rows.append(a, row); + } + } + /// Build a dim, full-width horizontal rule (box-drawing `─`). Caller owns /// the returned slice. fn horizontalRule(self: *InputBox, width: usize) ![]u8 { @@ -1079,6 +1151,7 @@ pub const InputBox = struct { /// cursor (or a space at end-of-line) and emits CURSOR_MARKER there. fn renderRow(self: *InputBox, line: []const u8, cursor_col: ?usize, cursor_style: Style, width: usize, focused: bool) ![]u8 { const a = self.alloc; + if (width == 0) return a.dupe(u8, ""); // The cursor block consumes one visible column, so usable text width // is width-1 when the cursor sits at/after the truncated end and we // must show the block. To keep it simple and always-safe: truncate the @@ -1632,19 +1705,18 @@ pub const Selector = struct { }; // =========================================================================== -// Welcome — session-start banner (plan §6: "version, cwd, model info") +// Welcome — session-start banner // =========================================================================== /// A static banner shown as the first transcript entry at session start. -/// Structured data in (version / cwd / model label via setters), lines out. -/// Re-rendered only when one of the fields changes (markDirty), which in -/// practice is once during bring-up. +/// Structured data in (version / cwd via setters), lines out. Re-rendered +/// only when one of the visible fields changes (markDirty), which in practice +/// is once during bring-up. pub const Welcome = struct { alloc: std.mem.Allocator, cache: RenderCache, version: std.ArrayList(u8) = .empty, cwd: std.ArrayList(u8) = .empty, - model: std.ArrayList(u8) = .empty, pub fn init(alloc: std.mem.Allocator) Welcome { return .{ .alloc = alloc, .cache = RenderCache.init(alloc) }; @@ -1653,7 +1725,6 @@ pub const Welcome = struct { pub fn deinit(self: *Welcome) void { self.version.deinit(self.alloc); self.cwd.deinit(self.alloc); - self.model.deinit(self.alloc); self.cache.deinit(); } @@ -1673,11 +1744,6 @@ pub const Welcome = struct { try self.setField(&self.cwd, value); } - /// Set the model label shown in the banner. - pub fn setModel(self: *Welcome, value: []const u8) !void { - try self.setField(&self.model, value); - } - fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 { _ = alloc; const self: *Welcome = @ptrCast(@alignCast(ptr)); @@ -1707,19 +1773,13 @@ pub const Welcome = struct { try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ accent.open(), vis, accent.close() })); } - // Detail lines (dim, with leading space): cwd and model, only when set. + // Detail lines (dim, with leading space): cwd only when set. if (self.cwd.items.len != 0) { const txt = try std.fmt.allocPrint(a, " cwd: {s}", .{self.cwd.items}); defer a.free(txt); const vis = truncateToCols(txt, width); try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() })); } - if (self.model.items.len != 0) { - const txt = try std.fmt.allocPrint(a, " model: {s}", .{self.model.items}); - defer a.free(txt); - const vis = truncateToCols(txt, width); - try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() })); - } _ = plain_bg; // Blank margin below. @@ -2366,7 +2426,35 @@ test "InputBox: cursor block fits within width at end of a full line" { for ("abcde") |c| try ib.applyKey(charKey(c, &[_]u8{c})); // width 5, cursor at end: the block must not overflow. const lines = try ib.comp().render(5, testing.allocator); - try testing.expect(vw(lines[1]) <= 5); + for (lines) |ln| try testing.expect(vw(ln) <= 5); +} + +test "InputBox: long logical line hard-wraps visually without inserting newlines" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + ib.setFocused(true); + try typeStr(&ib, "abcdef"); + const lines = try ib.comp().render(5, testing.allocator); + // Two content rows ("abcde" and "f"+cursor), plus top/bottom rules. + try testing.expectEqual(@as(usize, 4), lines.len); + try testing.expectEqualStrings("abcdef", ib.text.items); + try testing.expect(std.mem.indexOf(u8, lines[1], "abcde") != null); + try testing.expect(std.mem.indexOf(u8, lines[2], "f") != null); + try testing.expect(std.mem.indexOf(u8, lines[2], CURSOR_MARKER) != null); + for (lines) |ln| try testing.expect(vw(ln) <= 5); +} + +test "InputBox: line cap applies to visual wrapped rows" { + var ib = InputBox.init(testing.allocator); + defer ib.deinit(); + ib.line_cap = 2; + try typeStr(&ib, "abcde"); + const lines = try ib.comp().render(2, testing.allocator); + // Three wrapped content rows are capped to the tail two, plus rules. + try testing.expectEqual(@as(usize, 4), lines.len); + try testing.expect(std.mem.indexOf(u8, lines[1], "cd") != null); + try testing.expect(std.mem.indexOf(u8, lines[2], "e") != null); + for (lines) |ln| try testing.expect(vw(ln) <= 2); } fn ctrlKey(letter: u8) Key { @@ -2873,22 +2961,20 @@ test "components drive the real engine without a TTY" { // -- Welcome / Thinking / CompactionSummary / ToolUse (P2) ------------------ -test "Welcome: renders title + cwd + model, all within width" { +test "Welcome: renders title + cwd, all within width" { var w = Welcome.init(testing.allocator); defer w.deinit(); try w.setVersion("0.1.0"); try w.setCwd("/tmp/project"); - try w.setModel("anthropic:claude"); const lines = try w.comp().render(40, testing.allocator); - // 3 content lines + 2 margin = 5. - try testing.expectEqual(@as(usize, 5), lines.len); + // 2 content lines + 2 margin = 4. + try testing.expectEqual(@as(usize, 4), lines.len); for (lines) |l| try testing.expect(vw(l) <= 40); try testing.expect(std.mem.indexOf(u8, lines[1], "panto v0.1.0") != null); try testing.expect(std.mem.indexOf(u8, lines[2], "/tmp/project") != null); - try testing.expect(std.mem.indexOf(u8, lines[3], "anthropic:claude") != null); } -test "Welcome: title only when cwd/model unset" { +test "Welcome: title only when cwd unset" { var w = Welcome.init(testing.allocator); defer w.deinit(); const lines = try w.comp().render(20, testing.allocator); @@ -2902,11 +2988,10 @@ test "Welcome: honors the width contract at a tiny width" { defer w.deinit(); try w.setVersion("0.1.0"); try w.setCwd("/a/very/long/working/directory/path/that/overflows"); - try w.setModel("anthropic:claude-some-very-long-model-id"); - // Width 6: every banner row (title + cwd + model) must truncate to fit. - // 3 content lines + 2 margin = 5. + // Width 6: every banner row (title + cwd) must truncate to fit. + // 2 content lines + 2 margin = 4. const lines = try w.comp().render(6, testing.allocator); - try testing.expectEqual(@as(usize, 5), lines.len); + try testing.expectEqual(@as(usize, 4), lines.len); for (lines) |l| try testing.expect(vw(l) <= 6); } diff --git a/src/tui_selectors.zig b/src/tui_selectors.zig index 590d321..e76f82d 100644 --- a/src/tui_selectors.zig +++ b/src/tui_selectors.zig @@ -89,7 +89,7 @@ pub const ReasoningOption = struct { pub fn forStyle(style: APIStyle) []const ReasoningOption { return switch (style) { // The Responses style takes the same reasoning-effort options. - .openai_chat, .openai_responses => &openai, + .openai_chat, .openai_responses, .openai_codex_responses => &openai, .anthropic_messages => &anthropic, }; } @@ -100,6 +100,7 @@ pub const ReasoningOption = struct { .openai_chat => |r| switch (cfg.*) { .openai_chat => |*c| c.reasoning = r, .openai_responses => |*c| c.reasoning = r, + .openai_codex_responses => |*c| c.reasoning = r, .anthropic_messages => {}, }, .anthropic_messages => |sel| switch (cfg.*) { @@ -110,7 +111,7 @@ pub const ReasoningOption = struct { c.effort = e; }, }, - .openai_chat, .openai_responses => {}, + .openai_chat, .openai_responses, .openai_codex_responses => {}, }, } } @@ -120,7 +121,8 @@ pub const ReasoningOption = struct { pub fn matches(self: ReasoningOption, cfg: ProviderConfig) bool { switch (self.value) { .openai_chat => |r| return (cfg == .openai_chat and cfg.openai_chat.reasoning == r) or - (cfg == .openai_responses and cfg.openai_responses.reasoning == r), + (cfg == .openai_responses and cfg.openai_responses.reasoning == r) or + (cfg == .openai_codex_responses and cfg.openai_codex_responses.reasoning == r), .anthropic_messages => |sel| { if (cfg != .anthropic_messages) return false; const c = cfg.anthropic_messages; @@ -173,7 +175,7 @@ pub fn adaptiveFallback(cfg: *ProviderConfig) bool { c.thinking_budget_tokens = budget; return true; }, - .openai_chat, .openai_responses => return false, + .openai_chat, .openai_responses, .openai_codex_responses => return false, } } |
