diff --git a/CHANGELOG.md b/CHANGELOG.md index 10149a92..e4f3a3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,37 @@ The release workflow uses a tag's section here as its release notes (a hand-written `docs/releases/.md` wins if present), so keeping this file current is part of cutting a release. +## v0.0.282 (2026-08-31) + +- Next cut after v0.0.281 landed on main (`#670`). First fold: + atomic paste spans (`#674`), terminal Codex WS API errors + (`#693`), and recoverable HTTP client generations (`#694` / + ADR 0048). `#694` follow-up hardens lease teardown. +- `#697` / ADR 0049: `--resume SOURCE --branch DEST` clone-on-write + so two processes cannot clobber one `.session.json`. `#698` follow-ups: + 160 MiB learn-pin headroom for debug builds, source-nudge suffixes no + longer match `.json` / `.jsonl` as JavaScript, Codex compaction probe + reads Responses `input_text` blocks. +- `#679` / ADR 0050: MCP HTTP/WSS reuse the warmed TLS client; + MCP HTTP accepts gzip catalogs. +- `#695`: an empty tool catalog omits the `tools` field instead of + serializing `"tools":,` (0.0.280 HTTP 400). +- `#694`/`#697`/`#679` numbered records 0042/0043; those slots are + TUI claims and Pi SWE. Remapped to **0048–0050**. +- `#677` / ADR 0051: `/teleport` restores a snapshot tar onto another + CLI backend; `/snapshot gc` keeps the newest n trees. Leftover of + `#554`. `#677` numbered this 0042; remapped (0042 is TUI claims). +- `#675` / ADR 0052–0055: lean `-p` bounces a prose-only first "done"; + short Codegraff flakes retry twice; DeepSeek family default low + sends `thinking.type=disabled`; lean bash auto-backgrounds at 15s. + `#675` numbered those 0048–0051; remapped off 282's 0048–0051. +- `#277` (Smolify OAuth) and `#200` (idle localhost) closed as + abandoned vs tip; issues stay parked. +- Suite floor **1861** (was 1849). Scratch helpers live in + `agent_request_scratch.zig` so the request loop stays under 600. +- 12-task in-house remasure after #697 still unique vs grok / OpenCode: + **12/12 · 201s · 52 calls · $0.34** (`run-20260831-021035-composite`). + ## v0.0.281 (2026-08-29) - Next cut after tagged v0.0.280. Headline is the **hardlink** diff --git a/README.md b/README.md index 02db5b24..c3195995 100644 --- a/README.md +++ b/README.md @@ -54,17 +54,17 @@ Same grok-4.6, same SuperGrok seat, same tasks. graff vs grok-build vs OpenCode. Lower is better on every named axis. Full tables: [graff-evals/hillclimb/baseline.md](graff-evals/hillclimb/baseline.md). -**12 shipped-PR fixtures** (`run-20260830-141658`, `--suite inhouse`): +**12 shipped-PR fixtures** (`run-20260831-021035-composite` on 282, `--suite inhouse`): | harness | pass | wall | calls | tokens | list$ | RSS | |---|---:|---:|---:|---:|---:|---:| -| **graff** | **12/12** | **192s** | **52** | **228k** | **$0.35** | **92M** | -| grok-build | 12/12 | 462s | 63 | 1.18M | $1.12 | 170M | -| OpenCode | 12/12 | 236s | 74 | 675k | $0.79 | 1.1G | +| **graff** | **12/12** | **201s** | **52** | **230k** | **$0.34** | **9M** | +| grok-build | 12/12 | 388s | 64 | 1.17M | $1.05 | 161M | +| OpenCode | 12/12 | 310s | 81 | 737k | $0.93 | 1.1G | -Graff is the unique frontier on pass, wall, calls, tokens, list$, and RSS. -(First-token is not scored on that run — graff's `0.02s` is a boot mark, not -first model SSE.) +Graff is still the unique frontier on pass, wall, calls, tokens, list$, and RSS +after folding #697. (First-token is not scored — graff's `0.02s` is a boot +mark, not first model SSE. RSS is ReleaseSafe process peak.) On the 3-task spine (exact-reply + file-ops + fix-fib) graff was **19.9s / 8 calls / $0.048** vs grok 32.3s / 8 / $0.147 and OpenCode 31.2s / 8 / $0.101. diff --git a/TUI/catalog.zig b/TUI/catalog.zig index 0e045055..da899449 100644 --- a/TUI/catalog.zig +++ b/TUI/catalog.zig @@ -11,6 +11,7 @@ pub const Item = struct { pub const items = [_]Item{ .{ .name = "/new", .desc = "Start a fresh session", .aliases = &.{"/clear"} }, + .{ .name = "/resume", .desc = "Resume or branch a saved session" }, .{ .name = "/home", .desc = "Return to the welcome screen", .aliases = &.{"/welcome"} }, .{ .name = "/compact", .desc = "Engine-compact model-visible history" }, .{ .name = "/context", .desc = "Show context-window use" }, diff --git a/TUI/dispatch.zig b/TUI/dispatch.zig index e7ddb2c2..a5e2b115 100644 --- a/TUI/dispatch.zig +++ b/TUI/dispatch.zig @@ -8,6 +8,7 @@ const bgop = @import("bgop.zig"); const catalog = @import("catalog.zig"); const engine = @import("engine.zig"); const peer_cmd = @import("peer_cmd.zig"); +const resume_mod = @import("resume.zig"); const meters = @import("meters.zig"); const theme_mod = @import("theme.zig"); const turn = @import("turn.zig"); @@ -58,7 +59,7 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { // #521: history-destroying commands must not run under a live job — the // steer guard in promptKey only covers plain text, and the slash menu, // palette, and steer drain all land here. - const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind"); + const destroys = std.mem.eql(u8, canon, "/new") or std.mem.eql(u8, canon, "/compact") or std.mem.eql(u8, canon, "/rewind") or std.mem.eql(u8, canon, "/resume"); if (self.pending != null and destroys) { self.push(.system, "a turn is still running — press Esc to cancel it first") catch {}; return .stay; @@ -76,6 +77,8 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { _ = self.newSession(); // the `destroys` guard above already refused a live call self.push(.system, "started a new conversation") catch {}; self.screen = .welcome; + } else if (std.mem.eql(u8, canon, "/resume")) { + resume_mod.run(self, arg); } else if (std.mem.eql(u8, canon, "/home")) { self.screen = .welcome; self.focus = .prompt; @@ -142,17 +145,21 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { self.pushFmt(.system, "fast: {s}", .{onOff(self.fast)}) catch {}; } else if (std.mem.eql(u8, canon, "/ultracode")) { self.ultracode = !self.ultracode; + publishState(self); self.pushFmt(.system, "ultracode: {s}", .{onOff(self.ultracode)}) catch {}; } else if (std.mem.eql(u8, canon, "/strict")) { self.strict = !self.strict; + publishState(self); self.pushFmt(.system, "strict: {s}", .{onOff(self.strict)}) catch {}; } else if (std.mem.eql(u8, canon, "/goal")) { if (self.goal) |g| self.alloc.free(g); self.goal = if (arg.len > 0) (self.alloc.dupe(u8, arg) catch null) else null; + publishState(self); if (self.goal) |g| self.pushFmt(.system, "goal set: {s}", .{g}) catch {} else self.push(.system, "goal cleared") catch {}; } else if (std.mem.eql(u8, canon, "/rename")) { if (self.session_name) |s| self.alloc.free(s); self.session_name = if (arg.len > 0) (self.alloc.dupe(u8, arg) catch null) else null; + publishState(self); self.pushFmt(.system, "session: {s}", .{self.session_name orelse "untitled"}) catch {}; } else if (std.mem.eql(u8, canon, "/session-info")) { meters.sessionInfo(self); @@ -208,6 +215,15 @@ pub fn runCommand(self: *Model, line: []const u8) Effect { return if (self.quit_requested) .quit else .stay; } +fn publishState(self: *Model) void { + if (engine.g_state_fn) |f| f(engine.g_turn_ctx, .{ + .session_name = self.session_name orelse "", + .goal = self.goal orelse "", + .strict = self.strict, + .ultracode = self.ultracode, + }); +} + /// `!cmd` — run a shell line locally (grok-style bash mode) on a background /// thread, so a slow command no longer freezes the frame for its whole 20s /// cap (#533). Output stays out of the model history: EntryKind.system never diff --git a/TUI/engine.zig b/TUI/engine.zig index efa90405..94c5ad00 100644 --- a/TUI/engine.zig +++ b/TUI/engine.zig @@ -152,6 +152,24 @@ pub const CompactFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, pub const HistoryOp = enum { reset, rewind }; pub const HistoryFn = *const fn (turn_ctx: ?*anyopaque, op: HistoryOp) void; +pub const SessionState = struct { + session_name: []const u8 = "", + goal: []const u8 = "", + strict: bool = false, + ultracode: bool = false, +}; +pub const StateFn = *const fn (turn_ctx: ?*anyopaque, state: SessionState) void; + +pub const ResumeOut = struct { + turns: []Turn = &.{}, + session_name: []const u8 = "", + goal: []const u8 = "", + strict: bool = false, + ultracode: bool = false, + note: []const u8 = "", +}; +pub const ResumeFn = *const fn (turn_ctx: ?*anyopaque, gpa: std.mem.Allocator, spec: []const u8, out: *ResumeOut) bool; + pub const Job = struct { thread: std.Thread = undefined, threaded: bool = true, @@ -238,6 +256,11 @@ pub const RunOpts = struct { cancel_fn: ?CancelFn = null, model_name: []const u8 = "", model_provider: []const u8 = "", + initial_history: []const Turn = &.{}, + session_name: []const u8 = "", + initial_goal: []const u8 = "", + initial_strict: bool = false, + initial_ultracode: bool = false, /// The model catalog with its provider column (see ModelEntry). model_entries: []const ModelEntry = &.{}, cwd: []const u8 = ".", @@ -249,6 +272,9 @@ pub const RunOpts = struct { copy_fn: ?CopyFn = null, compact_fn: ?CompactFn = null, history_fn: ?HistoryFn = null, + resume_fn: ?ResumeFn = null, + state_fn: ?StateFn = null, + emergency_fn: ?*const fn (turn_ctx: ?*anyopaque) void = null, idle_wake_fn: ?IdleWakeFn = null, peer_fn: ?PeerFn = null, }; @@ -264,6 +290,8 @@ pub var g_files_fn: ?FilesFn = null; pub var g_copy_fn: ?CopyFn = null; pub var g_compact_fn: ?CompactFn = null; pub var g_history_fn: ?HistoryFn = null; +pub var g_resume_fn: ?ResumeFn = null; +pub var g_state_fn: ?StateFn = null; /// Tell the engine the transcript was cut. Silent when nothing is wired /// (offline TUI, unit tests). diff --git a/TUI/resume.zig b/TUI/resume.zig new file mode 100644 index 00000000..0192a67d --- /dev/null +++ b/TUI/resume.zig @@ -0,0 +1,70 @@ +//! Fullscreen `/resume SOURCE [--branch DEST]` projection. + +const std = @import("std"); +const app = @import("app.zig"); +const engine = @import("engine.zig"); + +pub fn run(self: *app.Model, spec: []const u8) void { + if (spec.len == 0) { + self.push(.system, "usage: /resume SOURCE [--branch DEST]") catch {}; + return; + } + const callback = engine.g_resume_fn orelse { + self.push(.system, "resume isn't available (offline)") catch {}; + return; + }; + var out: engine.ResumeOut = .{}; + if (!callback(engine.g_turn_ctx, self.alloc, spec, &out)) { + if (out.note.len > 0) { + self.push(.err, out.note) catch {}; + self.alloc.free(out.note); + } else self.push(.err, "resume failed") catch {}; + return; + } + self.clearHistory(); + for (out.turns) |turn| { + self.push(if (turn.role == .user) .user else .assistant, turn.text) catch {}; + self.alloc.free(turn.text); + } + if (out.turns.len > 0) self.alloc.free(out.turns); + self.turns = self.userTurnCount(); + if (self.session_name) |old| self.alloc.free(old); + self.session_name = out.session_name; + if (self.goal) |old| self.alloc.free(old); + self.goal = if (out.goal.len > 0) out.goal else null; + self.strict = out.strict; + self.ultracode = out.ultracode; + if (out.note.len > 0) { + self.push(.system, out.note) catch {}; + self.alloc.free(out.note); + } +} + +fn fakeResume(_: ?*anyopaque, gpa: std.mem.Allocator, spec: []const u8, out: *engine.ResumeOut) bool { + if (!std.mem.eql(u8, spec, "base --branch child")) return false; + const turns = gpa.alloc(engine.Turn, 2) catch return false; + turns[0] = .{ .role = .user, .text = gpa.dupe(u8, "baseline prompt") catch return false }; + turns[1] = .{ .role = .assistant, .text = gpa.dupe(u8, "baseline answer") catch return false }; + out.* = .{ + .turns = turns, + .session_name = gpa.dupe(u8, "child") catch return false, + .note = gpa.dupe(u8, "branched base → child") catch return false, + }; + return true; +} + +test "fullscreen resume replaces transcript and selects the branch" { + const saved = engine.g_resume_fn; + defer engine.g_resume_fn = saved; + engine.g_resume_fn = fakeResume; + var model: app.Model = undefined; + model.setup(std.testing.allocator); + defer model.deinit(); + try model.push(.user, "stale prompt"); + run(&model, "base --branch child"); + try std.testing.expectEqualStrings("child", model.session_name.?); + try std.testing.expectEqual(@as(usize, 3), model.history.items.len); + try std.testing.expectEqualStrings("baseline prompt", model.history.items[0].text); + try std.testing.expectEqualStrings("baseline answer", model.history.items[1].text); + try std.testing.expectEqualStrings("branched base → child", model.history.items[2].text); +} diff --git a/TUI/root.zig b/TUI/root.zig index d60f629d..7919550e 100644 --- a/TUI/root.zig +++ b/TUI/root.zig @@ -35,9 +35,17 @@ pub const CompactOut = engine.CompactOut; pub const CompactFn = engine.CompactFn; pub const HistoryOp = engine.HistoryOp; pub const HistoryFn = engine.HistoryFn; +pub const ResumeOut = engine.ResumeOut; +pub const ResumeFn = engine.ResumeFn; +pub const SessionState = engine.SessionState; +pub const StateFn = engine.StateFn; pub const PeerFn = engine.PeerFn; pub const RunOpts = run_mod.RunOpts; pub const run = run_mod.run; +pub fn setCurrentModel(name: []const u8, provider: []const u8) void { + engine.g_model_name = name; + engine.g_model_provider = provider; +} pub const restore = @import("restore.zig"); pub const enable_seq = run_mod.enable_seq; @@ -62,6 +70,7 @@ test { _ = app; _ = @import("app_tests.zig"); _ = @import("dispatch.zig"); + _ = @import("resume.zig"); _ = @import("peer_cmd.zig"); _ = @import("peer_tests.zig"); _ = @import("prompt_history.zig"); diff --git a/TUI/run.zig b/TUI/run.zig index 2fa161c2..afa919ec 100644 --- a/TUI/run.zig +++ b/TUI/run.zig @@ -55,6 +55,8 @@ pub fn run( engine.g_copy_fn = opts.copy_fn; engine.g_compact_fn = opts.compact_fn; engine.g_history_fn = opts.history_fn; + engine.g_resume_fn = opts.resume_fn; + engine.g_state_fn = opts.state_fn; engine.g_idle_wake_fn = opts.idle_wake_fn; engine.g_peer_fn = opts.peer_fn; engine.g_model_name = opts.model_name; @@ -65,6 +67,18 @@ pub fn run( var m: Model = undefined; m.setup(gpa); defer m.deinit(); + defer if (opts.state_fn) |f| f(opts.turn_ctx, .{ + .session_name = m.session_name orelse "", + .goal = m.goal orelse "", + .strict = m.strict, + .ultracode = m.ultracode, + }); + for (opts.initial_history) |item| m.push(if (item.role == .user) .user else .assistant, item.text) catch {}; + m.turns = m.userTurnCount(); + if (opts.session_name.len > 0) m.session_name = gpa.dupe(u8, opts.session_name) catch null; + if (opts.initial_goal.len > 0) m.goal = gpa.dupe(u8, opts.initial_goal) catch null; + m.strict = opts.initial_strict; + m.ultracode = opts.initial_ultracode; if (opts.yolo) m.mode = .always_approve; const claimed = restore_mod.takeClaim(); @@ -439,6 +453,7 @@ pub fn run( // The threads are still writing into the job and the op, so the // process must not outlive the restore: put the terminal back with // the same bytes the defers would have written, then leave. + if (opts.emergency_fn) |f| f(opts.turn_ctx); w.flush() catch {}; restore_mod.emergency(); std.process.exit(0); diff --git a/TUI/sim.zig b/TUI/sim.zig index ed429b8b..5d1463af 100644 --- a/TUI/sim.zig +++ b/TUI/sim.zig @@ -20,10 +20,12 @@ const std = @import("std"); const app = @import("app.zig"); const dump = @import("dump.zig"); +const engine_mod = @import("engine.zig"); const key_mod = @import("key.zig"); const keys = @import("keys.zig"); const render_mod = @import("render.zig"); const theme_mod = @import("theme.zig"); +const turn = @import("turn.zig"); const Model = app.Model; const Effect = app.Effect; @@ -221,6 +223,41 @@ test "typeText lands in the composer dump" { try std.testing.expect(std.mem.indexOf(u8, vis, "›") != null); } +test "#692: terminal API diagnostics survive job finish and the composer recovers" { + const a = std.testing.allocator; + var term: Term = undefined; + term.init(a, 80, 24); + defer term.deinit(); + + try term.model.push(.user, "trigger a provider rejection"); + try term.model.push(.pending, ""); + const job = try a.create(engine_mod.Job); + job.* = .{ + .gpa = a, + .history = &.{}, + .params = .{}, + .stream = .{}, + .raw = .{}, + .threaded = false, + .result = try a.dupe(u8, "codex api error [invalid_request_error]: mock bad request"), + }; + job.events.attach(a); + job.done.store(true, .release); + term.model.pending = job; + turn.finishJob(&term.model); + + const failed = try term.screen(); + defer a.free(failed); + try std.testing.expect(std.mem.indexOf(u8, failed, "invalid_request_error") != null); + try std.testing.expect(std.mem.indexOf(u8, failed, "check /model and your API key") == null); + + _ = term.typeText("continue"); + const recovered = try term.screen(); + defer a.free(recovered); + try std.testing.expect(std.mem.indexOf(u8, recovered, "continue") != null); + try std.testing.expect(std.mem.indexOf(u8, recovered, "›") != null); +} + test "assistant markdown shows lists, quotes, tasks, and rules" { var term: Term = undefined; term.init(std.testing.allocator, 80, 24); diff --git a/docs/adr/0037-experiment-pool-is-opt-in.md b/docs/adr/0037-experiment-pool-is-opt-in.md index b1641137..0b77d701 100644 --- a/docs/adr/0037-experiment-pool-is-opt-in.md +++ b/docs/adr/0037-experiment-pool-is-opt-in.md @@ -22,4 +22,5 @@ path, branch, keep-reason, and diffstat. `graff worktree list` tags them ## Consequences Seats are FIFO. A fourth child after `--experiment 3` uses normal isolation. -Dependent pipeline stages still need #295. Docker snapshots stay #554. +Dependent pipeline stages still need #295. Docker snapshots stay #554; +teleport and snapshot GC are ADR 0051. diff --git a/docs/adr/0048-model-http-client-recovery-uses-generations.md b/docs/adr/0048-model-http-client-recovery-uses-generations.md new file mode 100644 index 00000000..487e9269 --- /dev/null +++ b/docs/adr/0048-model-http-client-recovery-uses-generations.md @@ -0,0 +1,35 @@ +# 0048. Model HTTP client recovery uses leased generations + +Status: accepted 2026-08-31 + +## Context + +Issue #691 captured launch-scoped `TlsInitializationFailed` storms: once +`std.http.Client.request` failed during TLS construction, six retries and every +later TUI turn reused the same client and failed until process restart. The +existing #177 connection poison cannot help because no `Request` exists yet. +The launch client is also shared with concurrent root, compaction, title, +recap, and subagent traffic, so deinitializing it in place would race users. + +## Decision + +Model HTTP constructors lease an active launch-level client generation. A +request-construction `TlsInitializationFailed` retires that generation and +publishes a prewarmed replacement under one mutex. Existing requests keep the +retired generation alive through reference-counted leases; later retries and +turns resolve the original launch pointer to the replacement. Owned retired +generations are deinitialized only after their final lease releases. + +The original launch client is never reclaimed by the generation manager: +unrelated launch consumers still holding its pointer remain safe until normal +shutdown. All managed constructors wait for initial CA prewarm readiness, and +CA-prewarm plus request-construction failures leave distinct trace evidence. +Post-construction send/read failures retain #177's per-connection poison. + +## Consequences + +A recovered network can serve later root, synthetic, compaction, and child +model trajectories without replacing the process or durable session. Recovery +adds one mutex operation per model HTTP request and temporarily retains an old +client while requests from that generation are still in flight. WebSocket +transport remains independently managed; this record governs HTTP model calls. diff --git a/docs/adr/0049-resume-branches-have-independent-durable-identity.md b/docs/adr/0049-resume-branches-have-independent-durable-identity.md new file mode 100644 index 00000000..e3c1fcc2 --- /dev/null +++ b/docs/adr/0049-resume-branches-have-independent-durable-identity.md @@ -0,0 +1,43 @@ +# 0049. Resume branches have independent durable identity + +Status: accepted 2026-08-30 + +## Context + +Issue #689 reproduced silent loss when two processes resumed one session name. +The advisory writer lock serialized individual whole-file replacements, but it +could not turn two descendants into independent tips. It also exposed that the +fullscreen ACP-backed TUI rendered an empty conversation after startup resume +and bypassed the root final-save path. + +Provider-native tool/reasoning blocks and compaction boundaries make merging two +message arrays unsafe. A process-lifetime source lock would avoid corruption by +rejecting the second user, but would not provide the requested branch behavior. + +## Decision + +`--resume SOURCE --branch DESTINATION` and +`/resume SOURCE --branch DESTINATION` clone SOURCE once into a new durable +identity. DESTINATION must be new and distinct from SOURCE; creation uses an +exclusive filesystem claim so two processes cannot both win a previously +unused name. Future turns, transcripts, checkpoints, compaction, and shutdown +saves target only DESTINATION; the session header records `parent: SOURCE` and +the branch receives a fresh persisted cache/session UUID. + +The fullscreen TUI, TTY `graff repl`, scripted `graff repl`, and ACP startup all +consume the same restored provider-native history. The fullscreen frontend also +projects that history into visible rows and syncs its engine-owned conversation +back to the root before final save. + +A branch copies ADR 0014's peer cursor and unread inbox snapshot exactly once as +part of the session snapshot. Parent and child then persist their cursors +independently; neither replays the room and neither shares mutable inbox state. +Git worktree isolation remains a separate choice. + +## Consequences + +Concurrent continuations need distinct destination names, which keeps conflicts +explicit and makes reopening deterministic. Existing `/resume SOURCE` remains a +same-tip continuation for compatibility and is not safe as a branching command. +There is no automatic merge; future merge/cherry-pick work must understand +provider-native history rather than appending JSON arrays. diff --git a/docs/adr/0050-reuse-warmed-tls-on-known-networks.md b/docs/adr/0050-reuse-warmed-tls-on-known-networks.md new file mode 100644 index 00000000..99591ed7 --- /dev/null +++ b/docs/adr/0050-reuse-warmed-tls-on-known-networks.md @@ -0,0 +1,52 @@ +# 0050. Reuse warmed TLS state on the networks we already speak + +Status: accepted 2026-08-29 + +## Context + +The harness already talks to three networks: provider HTTP/SSE (and WS), +MCP Streamable HTTP, and the Codegraff gateway. Two patterns were wasting +a handshake on every first useful call: + +1. MCP Auto's modern `tools/list` probe and the legacy + `notifications/initialized` notify each built a throwaway `std.http.Client`, + so a successful probe's keep-alive died before `tools/call`, and initialized + paid a second TLS for a fire-and-forget 202. +2. Every WSS dial (`ws.WsClient.connect`) rescanned the host CA store from + disk, even though launch already warms the HTTP client's bundle. + +ADR 0002 (xAI WS full-resend), 0009/0011/0028 (prompt-cache keys), and 0035 +(deferred MCP join) stay untouched: this is transport reuse, not wire shape. + +## Decision + +- MCP HTTP probe and `notifications/initialized` use `server.transport.http`. + Do not construct a per-call client for those paths. +- WSS TLS uses a process-lifetime CA bundle warmed once (`http_warm.ensureProcessCa`). + A reconnect must not walk the host store again. +- WS→SSE fallback keeps the Agent's prewarmed HTTP pool (`postStream`). Do not + introduce a fresh client on that latch: WS never used the HTTP pool, and a + new TLS would be strictly more expensive. +- MCP Streamable HTTP advertises the std client's default Accept-Encoding + (gzip/deflate) and decompresses Content-Encoding. Do not omit it: a tools/list + catalog is the fat payload on that network, and provider POST already accepts + compression. + +## Consequences + +A modern MCP connect plus the next list is one TCP accept on keep-alive. +WSS reconnects skip the CA disk walk. MCP catalogs can travel gzip-compressed. +Revisit only if a shared `std.http.Client` is shown unsafe for the concurrent +initialized+list pair, a host CA rotation must be picked up mid-process without +restart, or a server is broken by Accept-Encoding. + +## Measured (2026-08-30, this host) + +| Path | Before | After | Left on the table | +|---|---|---|---| +| MCP modern connect + next `tools/list` | 2 TCP accepts (throwaway probe client, then the persistent one) | 1 accept, 2 POSTs | nothing — keep-alive is the rest | +| WSS CA disk walk | 1 `rescan` per connect, including every reconnect | 1 per process | launch still walks once for the HTTP client (~5–7 ms, 144 certs / 154 KB here). Cloning that bundle into the process one saves one launch scan and risks a double-free; not worth it | +| MCP `tools/list` catalog (40-tool fixture) | 6110 B raw (`Accept-Encoding` omitted) | 320 B gzip (5% of plaintext) | only if the server ignores gzip — then we still send the header and read identity | +| WS→SSE latch | prewarmed Agent HTTP pool | unchanged | a fresh client would add a TLS handshake | +| xAI / Codex WS turn body | full history | unchanged | ADR 0002: no `previous_response_id` chain | +| MCP OAuth token / login 401 probe | throwaway `std.http.Client`; login probe omits encoding | unchanged | not on the turn path | diff --git a/docs/adr/0051-sandbox-teleport-and-gc.md b/docs/adr/0051-sandbox-teleport-and-gc.md new file mode 100644 index 00000000..d67440c3 --- /dev/null +++ b/docs/adr/0051-sandbox-teleport-and-gc.md @@ -0,0 +1,25 @@ +# 0051. Sandbox leftover of #554 is teleport plus snapshot GC + +Status: accepted 2026-08-29 + +## Context + +#554 shipped the Docker CLI seam, `/snapshot`, and `/rewind `. Two exo +misses stayed open: restoring a `docker_image_tar` on a *different* backend, +and deleting leftover snapshot trees. ADR 0037 still pointed experiment-pool +Docker snapshots here. + +## Decision + +`/teleport [docker|container]` restores a captured tar onto another CLI +backend. Apple Container (`container`) is the second backend — same wire as +`docker`, different `bin_name`. Dest defaults to `container` when the live +backend is docker or nothing is attached. `/snapshot gc [n]` keeps the newest +n snapshots (default 1) and deletes the rest. The conversation is never +rewound. Missing CLIs stay explanations, not crashes. + +## Consequences + +Daytona is still not a backend. There is no `graff registry` client. A +snapshot's kind stays `docker_image_tar` even when the dest CLI is +`container`. Experiment-pool isolation is still opt-in worktrees (ADR 0037). diff --git a/docs/adr/0052-lean-oneshot-bounces-prose.md b/docs/adr/0052-lean-oneshot-bounces-prose.md new file mode 100644 index 00000000..3c8ebde1 --- /dev/null +++ b/docs/adr/0052-lean-oneshot-bounces-prose.md @@ -0,0 +1,63 @@ +# 0052. Lean `-p` bounces a first-turn prose-only "done" + +Status: accepted 2026-08-29 + +#675 numbered this 0048. 282 already owns 0048 (HTTP client generations); +this cut remaps the record. + +## Context + +ADR 0047 ran Codegraff `deepseek-v4-flash` SWE. Pi 5/6 in 492s. Graff +2/6 in 199s. The three misses (`map-conflict`, `json-stream`, +`validated`) were 4-second one-call replies (~1.5k in / ~160 out) that +described a patch and never called a tool. An isolated retry of those +three recovered two. Same miss as everyone on `label-sort`. + +Empty-completion retry only covers whitespace. A 158-token "I fixed +it" ends the turn. Forcing `tool_choice=required` is already reserved +for `--strict` / `--eval`; DeepSeek thinking mode has rejected it +before. Do not steal Pi's four-tool catalog (ADR 0024). + +## Decision + +On lean `-p` (unattended, file tools advertised, not review, not a +subagent), a first-turn completion that has text and zero tool calls +is not done. Append one user bounce naming `read_file` / `edit_file` / +`write_file` and re-open the loop. One bounce only (`model_calls == 1`). + +Lean intro also says a file change described in prose is not done. + +## Confirm — `deepseek-v4-flash` after bounce (`run-20260829-063114.jsonl`) + +Same `--suite swe -j 6` seat as ADR 0047. + +| harness | pass | wall (sum) | first | RSS | in | out | calls | +|---|---:|---:|---:|---:|---:|---:|---:| +| graff-dev | **4/6** | 250s | 0.03s | 91.2M | 170k | 25k | 26 | + +| task | before (0047) | after | +|---|---|---| +| cookie-store | ✓ 91s / 8 | ✓ 133s / 7 | +| config-parse | ✓ 33s / 4 | ✗ 5s / 1* | +| label-sort | ✗ 63s / 6 | ✗ 5s / 1* | +| map-conflict | ✗ 4s / 1 | ✓ 20s / 5 | +| json-stream | ✗ 4s / 1 | ✓ 65s / 8 | +| validated | ✗ 4s / 1 | ✓ 20s / 4 | + +\*follow-up API error under `-j 6` (resp 110 B, ~450 ms). Isolated +serial retry (`run-20260829-063451.jsonl`): `config-parse` ✓ 48s / 5, +`label-sort` ✗ 62s / 5 (check, exit 0). Do not stitch that into 5/6. + +The confirm run used tools on the first call (lean intro). Bounce is +the backstop when the model writes "I fixed it" with zero tools. No +`fake_done` notes on this run. + +## Consequences + +- `graff -p "what is 2+2"` pays one extra call if the first reply has + no tool. Acceptable for a coding harness. +- Interactive REPL / TUI / `--no-lean` / subagents are unchanged. +- Revisit if a named flash model needs `tool_choice=required` on the + first call; do not restore a global force. +- Follow-up `-j 6` flakes and the OpenCode A/B are ADR 0053 (graff + 5/6 in 362s after the retry). diff --git a/docs/adr/0053-codegraff-flake-retry-opencode.md b/docs/adr/0053-codegraff-flake-retry-opencode.md new file mode 100644 index 00000000..e939a41d --- /dev/null +++ b/docs/adr/0053-codegraff-flake-retry-opencode.md @@ -0,0 +1,56 @@ +# 0053. Retry short Codegraff follow-up flakes; OpenCode A/B on the same seat + +Status: accepted 2026-08-29 + +#675 numbered this 0049. 282 already owns 0049 (session branches); +this cut remaps the record. + +## Context + +ADR 0052 bounced lean `-p` prose-only first turns. DeepSeek flash SWE +moved 2/6 → 4/6. The leftover miss besides `label-sort` was +`config-parse` dying in ~5s after a successful 4-tool first batch: a +~110-byte / ~450 ms `api_error` under `-j 6`. Isolated serial retry +passed. Overload / `server_error` needles did not match. A missing +`error.message` becomes `unknown error` (`apiErrorMessage`). + +Same-seat OpenCode was missing from `graff-evals`. Fair A/B is the +same `CODEGRAFF_API_KEY` → `gateway.codegraff.com/v1`. Do not steal +Pi's four-tool catalog (ADR 0024) or OpenCode's ~1G heap. + +## Decision + +- Treat short generic envelopes (`unknown error`, `Internal Server + Error`, empty `api_error`) and tiny unparseable bodies (≤256 B) as + bounded retries (2). `invalid_request` / auth / quota / not-found + stay fail-fast. +- Add `opencode-codegraff` (`opencode run --auto --format json` + + tracked `opencode-codegraff.json`). Do not commit OpenCode auth. +- Eval failures keep a 400-byte `stderr_tail` so the next envelope is + visible. + +## Confirm — `deepseek-v4-flash` (`run-20260829-072310` + `072758`) + +Same `--suite swe -j 6` seat as ADR 0047 / 0052. + +| harness | pass | wall (sum) | first | RSS | in | out | calls | +|---|---:|---:|---:|---:|---:|---:|---:| +| graff-dev (`072758`) | **5/6** | 362s | 0.05s | 91.1M | 346k | 38k | 38 | +| opencode-codegraff (`072310`) | **5/6** | 261s | 3.6s | 1064M | 61k | 8k | 36 | +| pi-codegraff (0047) | **5/6** | 492s | 0.7s | 186M | 480k | 56k | 46 | + +The parallel A/B (`072310`) still had graff 4/6 (`config-parse` 5s / +1). After the `unknown error` / tiny-body retry, graff `072758` is +5/6; `config-parse` ✓ 30s / 4. Same `label-sort` miss on all three +harnesses. Do not stitch the two graff suite scores. + +OpenCode `first` is the first JSONL event (~3.6s), not TUI paint. +Graff `first` is the stderr `calling` line. Do not treat 0.05s as a +TUI steal. + +## Consequences + +- Score is now tied with Pi and OpenCode. Wall is not: OpenCode 261s + vs graff 362s is mostly `cookie-store` variance (89s on `072310`, + 185s on `072758`). RSS stays ~91M; do not take OpenCode's heap. +- Revisit if a named 400 should retry; do not retry `invalid_request`. diff --git a/docs/adr/0054-deepseek-thinking-disabled-at-low.md b/docs/adr/0054-deepseek-thinking-disabled-at-low.md new file mode 100644 index 00000000..2b599274 --- /dev/null +++ b/docs/adr/0054-deepseek-thinking-disabled-at-low.md @@ -0,0 +1,51 @@ +# 0054. DeepSeek flash default is thinking off + +Status: accepted 2026-08-29 + +#675 numbered this 0050. 282 already owns 0050 (warmed TLS); +this cut remaps the record. + +## Context + +ADR 0046 maps flash default `.medium` to `reasoning_effort: low`. +That stops GLM/Gemini thinking novels. DeepSeek V4 is different: +thinking is **on at high** unless the request carries +`thinking: {type: disabled}`. Official docs: `low` still thinks; +it only lowers CoT. `none` is a 400 on Chat Completions. + +DeepSeek flash SWE after ADR 0053: graff **5/6 in 362s** vs OpenCode +**5/6 in 261s**. The 100s gap is not catalog or heap. cookie-store +call 2 was **75s / 1.9MB** of `reasoning_content` at `low`. An earlier +A/B without that dump was 89s / 9 calls (OpenCode 76s / 9). Pi sets +`reasoning: false`. OpenCode omits the field (default = thinking on); +it still won on a no-dump draw. Do not steal Pi's four-tool catalog +or OpenCode's ~1G heap. + +## Decision + +DeepSeek family (native `deepseek`, or a `deepseek*` model on +codegraff/fireworks) sends `thinking.type=disabled` when the wire +effort is `low` (flash default). `/effort high` (and any non-low) +sends `type=enabled` plus that effort. GLM keeps `low` only — +`type=disabled` still thinks there (ADR 0046). Do not shrink the +keep-list. + +## Consequences + +- Flash SWE wall is generation without CoT dumps, not Zig vs JS. +- `/effort` remains the only way thinking comes back (no auto-flip). +- Revisit if a named DeepSeek flash pin needs default thinking. + +## Confirm (2026-08-29) + +Live `deepseek-v4-flash` pong on `gateway.codegraff.com`: + +| knob | reasoning_tokens | rc_len | +|---|---:|---:| +| omit | 21 | 79 | +| `reasoning_effort=low` | 31 | 112 | +| `thinking.type=disabled` | **0** | **0** | +| disabled + low | **0** | **0** | +| enabled + high | 29 | 117 | + +`--suite swe -j 6` after this cut (`074142`): 4/6 in **210s** / 91M (cookie-store 83s / 14, no 1.9MB dump). `config-parse` was the 110-byte “Body must be valid JSON” flake (retry in the same branch). After that retry (`074659`): **5/6 in 353s** — leftover wall was hung bash (ADR 0055), not CoT. Same `label-sort` miss. Later 0-token / balance-reservation runs are burnt gateway, not a model result. Do not steal Pi’s catalog. diff --git a/docs/adr/0055-lean-oneshot-bash-15s.md b/docs/adr/0055-lean-oneshot-bash-15s.md new file mode 100644 index 00000000..6bbbff0a --- /dev/null +++ b/docs/adr/0055-lean-oneshot-bash-15s.md @@ -0,0 +1,35 @@ +# 0055. Lean `-p` bash auto-backgrounds at 15s + +Status: accepted 2026-08-29 + +#675 numbered this 0051. 282 already owns 0051 (sandbox teleport / GC); +this cut remaps the record. + +## Context + +ADR 0026 waits 120s before promoting root foreground bash: a human +may be watching a long compile. Lean `-p` has no human. After ADR +0054 killed DeepSeek CoT dumps, SWE wall was **5/6 in 353s** — +`json-stream` sat **120s** on `python3 test_json_stream.py`, +`cookie-store` **71s** on another hang. API calls were 2–13s. +OpenCode’s 261s lead was those waits, not catalog or heap. + +## Decision + +Unattended + lean (`-p` default) auto-backgrounds at **15s**. The +process is not killed (ADR 0026). An explicit `timeout` still wins. +Interactive / `--no-lean` stay 120s. Do not steal Pi’s catalog. + +## Consequences + +A hung test returns a job id in 15s; the model can edit or +`bash_kill`. A 20-minute compile on `-p` also backgrounds at 15s — +`bash_output(wait_ms>0)` still waits for exit (ADR 0010). + +## Confirm (2026-08-29) + +`run-20260829-075402`, thinking off + 15s bash. cookie-store **30s** +(was 114s / 71s hang). json-stream **22s** (was 155s / 120s hang). +Suite 4/6 in **143s** / 91M — `validated` died on a concurrent +reservation / empty balance, not the wait. Same `label-sort` miss. +0-token follow-ups after that are burnt gateway. Do not stitch. diff --git a/docs/adr/README.md b/docs/adr/README.md index c769cac1..5bd49c01 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,6 +58,14 @@ record only when you need the evidence or the edge cases. | [0045](0045-glm-flash-swe-codegraff.md) | Codegraff `glm-5.3-flash` SWE: Pi 5/6 in 758s, graff 3/6 with three 300s timeouts; do not steal Pi's heap. | | [0046](0046-flash-omits-default-effort.md) | Flash / Gemini send `reasoning_effort=low` (omit still thinks); lean `-p` shortens tool prose; `-p` streams. | | [0047](0047-codegraff-swe-not-glm-only.md) | Codegraff SWE A/B is not GLM-only: Gemini graff 5/6 in 103s; DeepSeek flash still one-shots; do not steal Pi's catalog. | +| [0048](0048-model-http-client-recovery-uses-generations.md) | Model HTTP calls lease a recoverable client generation; request-construction TLS failure rotates safely without deinitializing in-flight users. | +| [0049](0049-resume-branches-have-independent-durable-identity.md) | `--resume SOURCE --branch DEST` clones provider history and peer cursor state once; every later save belongs only to DEST. | +| [0050](0050-reuse-warmed-tls-on-known-networks.md) | Reuse warmed TLS: MCP probe/initialized stay on the persistent HTTP client; WSS CA is scanned once per process; WS→SSE keeps the prewarmed pool; MCP HTTP accepts gzip. | +| [0051](0051-sandbox-teleport-and-gc.md) | `/teleport` restores a snapshot tar onto another CLI backend; `/snapshot gc` keeps the newest n trees. | +| [0052](0052-lean-oneshot-bounces-prose.md) | Lean `-p` bounces a first-turn prose-only "done"; one user note naming file tools, then re-open. | +| [0053](0053-codegraff-flake-retry-opencode.md) | Retry short Codegraff follow-up flakes (2); `opencode-codegraff` A/B on the same seat. Auth/quota stay fail-fast. | +| [0054](0054-deepseek-thinking-disabled-at-low.md) | DeepSeek family default low sends `thinking.type=disabled`; `/effort high` still thinks. GLM stays low-only. | +| [0055](0055-lean-oneshot-bash-15s.md) | Lean `-p` bash auto-backgrounds at 15s; interactive / `--no-lean` stay 120s (ADR 0026). | ## When to write one diff --git a/docs/releases/v0.0.282.md b/docs/releases/v0.0.282.md new file mode 100644 index 00000000..1c445f38 --- /dev/null +++ b/docs/releases/v0.0.282.md @@ -0,0 +1,144 @@ +# v0.0.282 (2026-08-31) + +Next cut after [v0.0.281](v0.0.281.md) merged to main (`#670`). The +headline is three yxlyx product PRs that were open against the old +main tip: atomic paste spans, terminal Codex WS errors, and +recoverable HTTP client generations. + +281 stays published as-is. This is not a retag. No GitHub tag until +asked; download / notarization ids land after the build. + +## Atomic pasted-text placeholders (#674 / #673) + +The composer used to store a long paste body out of band and represent +it with ordinary editable text. Submit searched for the exact display +string and replaced every match, so Option+Backspace chewed the marker +word by word and a typed lookalike could resurrect a hidden body. + +`readline` now owns a semantic paste store: each collapsed +`[Pasted text #N +L lines]` chip is a positional span. Navigation and +deletion treat it as one unit. Expansion happens only for a live span +and consumes its identity, so a typed lookalike stays literal. History +replay drops identity (history has no hidden body to restore). +`readline_replay.zig` holds history replay so `readline.zig` stays +under the 600-line ceiling. + +## Codex WS `type:error` is a terminal API response (#693 / #692) + +Codex sends a useful `type:error` frame and then closes the socket. +Graff recognized only two special codes as terminal; a generic error +waited for another frame, treated the expected close as transport +loss, discarded the API body, and redialed. Users saw `Bad Request` +and the fullscreen TUI replaced the failure with an API-key row. + +Every authoritative Responses `type:error` is now a terminal API +response. A bounded single-line `provider/code/message` diagnostic +rides `last_api_error` (no raw envelope). A stale +`previous_response_id` rebuilds full input exactly once. Generic +deterministic API errors return immediately and do not consume the +WS-retry / SSE-fallback ladder. + +## Model HTTP client generations (ADR 0048 / #694 / #691) + +`TlsInitializationFailed` at `std.http.Client.request` happens before +a `Request` exists, so the #177 connection-poison path cannot mark +anything closing. Retrying the same launch-owned client left every +later TUI turn failing until process restart. + +Model HTTP constructors lease an active launch-level generation. A +construction TLS failure retires that generation and publishes a +prewarmed replacement. In-flight callers keep the retired generation +through reference-counted leases; later retries resolve the original +launch pointer to the replacement. The original launch client is never +reclaimed by the manager (unrelated launch consumers still hold its +pointer). WebSocket transport stays independently managed. + +`#694` recorded this as ADR 0042. That number is already TUI claims +screen on main. This cut remaps the record to **0048**. The follow-up +hardens lease teardown (drain on shutdown, retry accounting, child +coverage) and keeps 0048. + +## Clone-on-write session branches (ADR 0049 / #697 / #689) + +Two processes resuming the same name wrote the same `.session.json`. +`--resume SOURCE --branch DEST` (and `/resume … --branch`) clones +provider history and the peer cursor once; later saves belong only to +DEST. A filesystem-exclusive claim stops a destination race. Branches +do not auto-merge. `#697` numbered this 0042; remapped to **0049**. `#698` reopened the +same head with three follow-ups now on this tip: raise the learn-pin +ceiling to 160 MiB so a Linux debug image still pins after session +branching linked in, match source suffixes at a token boundary so a +compaction handoff's `.session.json` / `.transcript.jsonl` is not a +JavaScript nudge, and parse Codex compaction user text from either a +chat string or Responses `input_text` blocks. + +## Warmed TLS on MCP HTTP and WSS (ADR 0050 / #679) + +MCP probe/initialized stay on the persistent HTTP client. WSS CA is +scanned once per process. WS→SSE keeps the prewarmed pool. MCP HTTP +accepts gzip catalogs. `#679` numbered this 0043; remapped to **0050**. + +## Empty tool catalog is omitted (#695) + +A wide-native RLM showcase that invalidated the root catalog and did +not rebuild it serialized `"tools":,` and every provider 400'd +(reproduced on 0.0.280). Showcase callers rebuild now; `buildBody` +also omits the field when the catalog string is empty — the same +shape `text_only` already uses. Responses, Chat, and Anthropic +bodies are pinned. + +## 12-task in-house remasure after #697 + +Same SuperGrok seat, jobs=1, grok-4.6, `x_search` on. Graff is still +the unique frontier vs grok-build and OpenCode on pass / wall / calls / +tokens / list$ / RSS (`run-20260831-021035-composite`). Calls stayed +52. list$ ticked down. Wall is +9s vs the 141658 pin, almost all on +one model-variance retry of `atomic-symlink-write`. `peer-resume` +(the #697-shaped fixture) passed. First-token is not a named win. + +## Sandbox teleport and snapshot GC (ADR 0051 / #677) + +Leftover of `#554`. `/teleport [docker|container]` restores a +captured `docker_image_tar` onto another CLI backend. Apple Container +is the second backend (same wire, different `bin_name`). Dest defaults +to `container` when the live backend is docker or nothing is attached. +`/snapshot gc [n]` keeps the newest n snapshot trees (default 1). +The conversation is never rewound. Missing CLIs explain, they do not +crash. `#677` numbered this 0042; remapped to **0051**. + +## DeepSeek thinking-off and lean 15s bash (ADR 0052–0055 / #675) + +Hillclimb of DeepSeek flash SWE on the Codegraff seat. Do not steal +Pi's four-tool catalog or OpenCode's ~1G heap. + +1. Lean `-p` bounces a first-turn prose-only "done" (ADR 0052). +2. Short Codegraff follow-up flakes retry twice (ADR 0053), including + the 110-byte `invalid_request_error` / "Body must be valid JSON". + Auth / quota / a real invalid prompt stay fail-fast. + `opencode-codegraff` is the same-seat OpenCode A/B harness. +3. DeepSeek family default low sends `thinking.type=disabled` + (ADR 0054). `/effort high` still thinks. GLM stays `low`-only. +4. Lean `-p` bash auto-backgrounds at 15s (ADR 0055). Interactive + stays 120s (ADR 0026). + +`#675` numbered these 0048–0051. 282 already owns 0048–0051; remapped +to **0052–0055**. Keep-alive-only bodies share the short-flake retry +in `agent_gateway_retry.zig` so 282's request-scratch split stays. + +## Parked + +`#277` (Streamable HTTP OAuth / Smolify) and `#200` (idle localhost +servers) were closed as abandoned vs tip; the issues stay inventory in +[yxlyx-leftovers.md](../yxlyx-leftovers.md). Smolify is not a reserved +core MCP. + +## Tests + +This cut raises the release-cut floor to **1861** (slack 25). Paste +spans, Codex WS errors, HTTP generations, session branches, MCP +TLS-reuse, empty-catalog omit, teleport/GC, and DeepSeek/lean-bash +tests land on top of the 1804 floor from 281. The suite count is +re-ratcheted after `zig build test` on this revision. +`agent_request.zig` split request-scratch helpers into +`agent_request_scratch.zig` so the Codex WS merge stays under the +600-line ceiling. diff --git a/docs/yxlyx-leftovers.md b/docs/yxlyx-leftovers.md index dc5b6714..76c4b3db 100644 --- a/docs/yxlyx-leftovers.md +++ b/docs/yxlyx-leftovers.md @@ -2,6 +2,14 @@ What still exists in this repo versus what is parked. No new product mode. +Landed on [v0.0.282](releases/v0.0.282.md): `#674` atomic paste spans, +`#693` Codex WS `type:error`, `#694` HTTP client generations (ADR 0048), +`#697` clone-on-write session branches (ADR 0049; `#698` was a yxlyx reopen +of the same head and is closed as duplicate), `#679` MCP TLS reuse +(ADR 0050), `#695` omit an empty tool catalog, `#677` `/teleport` + +`/snapshot gc` (ADR 0051), `#675` DeepSeek thinking-off / lean 15s bash +(ADR 0052–0055). Still parked below. + | Issue | In this repo today | This cut | | --- | --- | --- | | [#321](https://github.com/justrach/codegraff/issues/321) `/doctor` | Goal/todo slice shipped: `/doctor` in `src/doctor.zig` + `commands_misc` dispatch. Checks: `GOAL_STATE`, `GOAL_TODO_EPOCH_MISMATCH`, `STALE_GOAL`, `TODO_EPOCH_ABOVE_GOAL`, `COMPLETION_GATE_ARMED`. Read-only; JSON via `toJson`. | Landed earlier. **Parked:** session-lease, owned-job, listener, and aggregate-budget findings (`DUPLICATE_WORKTREE_OWNER`, `STALE_SESSION_LEASE`, `ORPHANED_OWNED_JOB`, …). Those need a durable job/session registry `doctor.zig` refuses to fake. | @@ -9,7 +17,7 @@ What still exists in this repo versus what is parked. No new product mode. | [#220](https://github.com/justrach/codegraff/issues/220) protected verifiers | `--eval` / `--until` scores a command the agent can invoke. ADR [0008](adr/0008-synthetic-evals-use-external-verifiers.md) is the eval-suite rule, not a `/goal` acceptance contract. | **Parked.** Independent verifier stages and hidden tests are a new controller, not a docs tweak. | | [#306](https://github.com/justrach/codegraff/issues/306) review runaway | Same budget knobs as #217. `/review` is one isolated pass (catalog). No review-specific cycle cap or "confirm before implementing" gate. | **Parked** on the #217 remainder. | | [#283](https://github.com/justrach/codegraff/issues/283) cube NDJSON buffer | `graff serve` already emits NDJSON as events happen. The failure is Daytona preview buffering the response and iOS `URLSession` idling out. `graff cube` / `src/cube.zig` is the sandbox+serve CLI. | **Parked** (ingress + iOS client). Not a new harness mode. | -| [#199](https://github.com/justrach/codegraff/issues/199) idle localhost servers | No `graff processes` / idle supervisor. Jobs exist for tool-started bash; they do not pause forgotten `next dev` trees. PR #200 is still open. | **Parked.** Do not start a process-supervisor product on this branch. | +| [#199](https://github.com/justrach/codegraff/issues/199) idle localhost servers | No `graff processes` / idle supervisor. Jobs exist for tool-started bash; they do not pause forgotten `next dev` trees. PR #200 closed as abandoned vs tip. | **Parked.** Do not start a process-supervisor product on this branch. | | [#106](https://github.com/justrach/codegraff/issues/106) representative Ultracode sims | Unit suite + `TUI/sim.zig` Term + tier-2 harness cases. Not a multi-turn human Ultracode drama. | **Parked** (eval/tier-2 work, not 279 continuation). | #218 / #219 (siblings under #216 governed runs) follow #217/#220: do not open a second budget product here. diff --git a/graff-evals/README.md b/graff-evals/README.md index e27b6acd..a287a166 100644 --- a/graff-evals/README.md +++ b/graff-evals/README.md @@ -32,6 +32,8 @@ harness under test spends model calls. CODEGRAFF_API_KEY=cg_sk_… ./run.py --suite swe --harness graff-dev,pi-codegraff --model glm-5.3-flash -j 6 # same seat, other models (ADR 0047): deepseek-v4-flash, gemini-3.7-flash, kimi-k2.6 CODEGRAFF_API_KEY=cg_sk_… ./run.py --suite swe --harness graff-dev,pi-codegraff --model deepseek-v4-flash -j 6 +# same seat, OpenCode vs graff (ADR 0053; needs `opencode` on PATH): +CODEGRAFF_API_KEY=cg_sk_… ./run.py --suite swe --harness graff-dev,opencode-codegraff --model deepseek-v4-flash -j 6 # multi-harness in-house PR suite (OpenCode needs --dir; see harnesses.json): ./run.py --suite inhouse --harness graff-dev,grok,opencode --model grok-4.6 -j 1 ``` diff --git a/graff-evals/harnesses.json b/graff-evals/harnesses.json index b17c0caa..93575b24 100644 --- a/graff-evals/harnesses.json +++ b/graff-evals/harnesses.json @@ -149,6 +149,14 @@ "default_model": "gemini-3.7-flash", "note": "uses ~/.pi/agent/models.json provider codegraff → gateway.codegraff.com; Gemini tool loops need the codegraff-gemini-echo extension" }, + "opencode-codegraff": { + "cmd": ["{repo}/graff-evals/opencode-codegraff.sh", "-m", "codegraff/{model}", "--dir", "{sandbox}", "{prompt}"], + "answer": "opencode-json", + "usage": "opencode-json", + "capabilities": [], + "default_model": "deepseek-v4-flash", + "note": "OpenCode on the Codegraff gateway (same CODEGRAFF_API_KEY seat as graff-dev / pi-codegraff). Wrapper needs `opencode` on PATH (~/.opencode/bin)." + }, "pi-xai": { "cmd": ["{repo}/graff-evals/pi-xai.sh", "-p", "--mode", "json", "--provider", "xai", "--model", "{model}", "--no-session", "{prompt}"], "answer": "pi-json", diff --git a/graff-evals/hillclimb/baseline.md b/graff-evals/hillclimb/baseline.md index 5c817bb6..27150a08 100644 --- a/graff-evals/hillclimb/baseline.md +++ b/graff-evals/hillclimb/baseline.md @@ -26,7 +26,35 @@ In-house 6 PR fixtures: | grok | 5/6 | 396s | 2.4s | 30 | 535344 | $0.4285 | 157.7M | | opencode | **6/6** | 182.1s | 2.8s | 37 | 340074 | $0.3835 | 1103.9M | -## 12-task in-house remasure (`run-20260830-141658`) +## 12-task in-house remasure after #697 (`run-20260831-021035-composite`) + +Same SuperGrok seat, jobs=1, grok-4.6, `x_search` on, ReleaseSafe +`graff-dev` from `release/v0.0.282` (`b1fcf67`, includes #697 session +branches and #695 empty-catalog omit). Composite of `run-20260831-015222` +(graff 11 + OpenCode 12) + `run-20260831-020406` (grok 12, after +restoring `~/.grok/auth.json`) + `run-20260831-021035` (graff +`atomic-symlink-write` retry — first attempt was a dangling-symlink +model miss, not a harness fail). All three harnesses **12/12**. + +| harness | pass | wall | first | calls | tokens | list$ | RSS | +|---|---:|---:|---:|---:|---:|---:| +| graff-dev | **12/12** | **200.9s** | 0.02s† | **52** | **230028** | **$0.3410** | **8.8M** | +| grok | 12/12 | 387.7s | 2.5s | 64 | 1170113 | $1.0543 | 160.9M | +| opencode | 12/12 | 310.4s | 2.9s | 81 | 737178 | $0.9326 | 1149.9M | + +† Graff `first_out_s` is still boot/`›`, not TTFT. Unique frontier on +pass / wall / calls / tokens / list$ / RSS. First-token is **not** a +named win. Calls stayed **52**. list$ ticked down ($0.3504 → $0.3410). +Wall is +8.8s vs the 141658 pin, almost all on `atomic-symlink-write` +(75s vs 49s, still 4 calls) — model path, not a session-branch +regression. `peer-resume` (the #697-shaped fixture) passed and got +slightly faster (8.14s vs 9.73s). RSS is ReleaseSafe process peak +(child HWM ~20M); the 91.7M pin was a fatter binary. Not a grok-heap +steal (ADR 0024). + +![12-task in-house frontier after #697](frontier-inhouse-12-20260831-282.svg) + +## Prior 12-task pin (`run-20260830-141658`) Same SuperGrok seat, jobs=1, grok-4.6, `x_search` on, the six original fixtures plus #690 (`hardlink-pin`, `x-search-splice`, `mcp-first-turn`, diff --git a/graff-evals/hillclimb/frontier-inhouse-12-20260831-282.svg b/graff-evals/hillclimb/frontier-inhouse-12-20260831-282.svg new file mode 100644 index 00000000..761cca11 --- /dev/null +++ b/graff-evals/hillclimb/frontier-inhouse-12-20260831-282.svg @@ -0,0 +1,120 @@ + +Eval frontier · lower-left is better + +Eval frontier · lower-left is better +Pass is a tie-break, not an axis. Hollow = mixed-model / REPL. REPL first-token is echo — not plotted. + + +wall vs list$ +calls vs list$ +wall (s) → +tool calls → + + +$0.000 +$0.000 + + +$0.303 +$0.303 + + +$0.606 +$0.606 + + +$0.909 +$0.909 + + +$1.212 +$1.212 + +171s + +237s + +303s + +368s + +434s + +52 + +53 + +54 + +55 + +56 + +57 + +58 + +59 + +60 + +61 + +62 + +63 + +64 + +65 + +66 + +67 + +68 + +69 + +70 + +71 + +72 + +73 + +74 + +75 + +76 + +77 + +78 + +79 + +80 + +81 + +graff-dev 200.9s · $0.3410 + +graff-dev 52 · $0.3410 + +graff-dev — 12/12 · on wall/$ + calls/$ + +opencode 310.4s · $0.9326 + +opencode 81 · $0.9326 + +opencode — 12/12 · interior + +grok 387.7s · $1.0543 + +grok 64 · $1.0543 + +grok — 12/12 · interior + diff --git a/graff-evals/opencode-codegraff.json b/graff-evals/opencode-codegraff.json new file mode 100644 index 00000000..bb4dd642 --- /dev/null +++ b/graff-evals/opencode-codegraff.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://opencode.ai/config.json", + "model": "codegraff/deepseek-v4-flash", + "provider": { + "codegraff": { + "npm": "@ai-sdk/openai-compatible", + "name": "Codegraff", + "options": { + "baseURL": "https://gateway.codegraff.com/v1", + "apiKey": "{env:CODEGRAFF_API_KEY}", + "headers": { + "User-Agent": "Mozilla/5.0 (compatible; graff-evals/1.0)" + } + }, + "models": { + "deepseek-v4-flash": { + "name": "DeepSeek V4 Flash" + }, + "glm-5.3-flash": { + "name": "GLM 5.3 Flash" + }, + "gemini-3.7-flash": { + "name": "Gemini 3.7 Flash" + }, + "kimi-k2.6": { + "name": "Kimi K2.6" + } + } + } + } +} diff --git a/graff-evals/opencode-codegraff.sh b/graff-evals/opencode-codegraff.sh new file mode 100755 index 00000000..e54094d0 --- /dev/null +++ b/graff-evals/opencode-codegraff.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Drive OpenCode on the Codegraff gateway (same seat as graff-dev / pi-codegraff). +set -e +root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +export PATH="${HOME}/.opencode/bin:${PATH}" +export OPENCODE_CONFIG="${OPENCODE_CONFIG:-$root/graff-evals/opencode-codegraff.json}" +export OPENCODE_DISABLE_AUTOUPDATE=1 +export OPENCODE_DISABLE_DEFAULT_PLUGINS=1 +if [ -z "${CODEGRAFF_API_KEY:-}" ]; then + echo "opencode-codegraff: CODEGRAFF_API_KEY is unset" >&2 + exit 127 +fi +if ! command -v opencode >/dev/null 2>&1; then + echo "opencode-codegraff: opencode not found (https://opencode.ai/docs)" >&2 + exit 127 +fi +exec opencode run --auto --format json --pure "$@" diff --git a/scripts/codex_ws_error_test.py b/scripts/codex_ws_error_test.py new file mode 100644 index 00000000..d35c82cc --- /dev/null +++ b/scripts/codex_ws_error_test.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Issue #692 interactive Codex WS error-frame regression scenarios.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from codex_ws_mock import CodexMock, RecordedRequest +import codex_ws_test as base + +GENERIC_ERROR_CODE = "invalid_request_error" +GENERIC_ERROR_SECRET = "raw-envelope-secret-must-not-leak" +GENERIC_ERROR_TAIL = "generic-error-tail-must-be-truncated" +GENERIC_ERROR_MESSAGE = "mock bad request\r\n" + "x" * 500 + GENERIC_ERROR_TAIL +CHAIN_ERROR_CODE = "previous_response_not_found" +CHAIN_FINAL = "recovered after automatic websocket re-anchor" + + +def generic_error_events(_request: RecordedRequest) -> list[dict]: + """One generic terminal error with raw envelope data that must not leak.""" + return [ + { + "type": "error", + "error": { + "code": GENERIC_ERROR_CODE, + "message": GENERIC_ERROR_MESSAGE, + }, + "debug": {"authorization": GENERIC_ERROR_SECRET}, + } + ] + + +def chain_error_events(request: RecordedRequest) -> list[dict]: + """Tool call, stale chained delta, then success after a full-input redial.""" + if request.ordinal == 1: + call = { + "type": "function_call", + "id": "fc_chain_1", + "call_id": "call_chain_1", + "name": "todo_read", + "arguments": "{}", + "status": "completed", + } + return base.response_events(call, "resp_chain_1", 1_200) + if request.ordinal == 2: + return [ + { + "type": "error", + "error": { + "code": CHAIN_ERROR_CODE, + "message": "Previous response resp_chain_1 was not found", + }, + } + ] + return base.response_events( + base.message_item(CHAIN_FINAL, "msg_chain_final"), + "resp_chain_final", + 1_300, + ) + + +def _env(tmp: str, codex_home: str, port: int) -> tuple[dict[str, str], tuple[str, ...]]: + env = { + "HOME": tmp, + "CODEX_HOME": codex_home, + "CODEGRAFF_API_KEY": "local-pty-test", + "GRAFF_FLEET": "off", + "GRAFF_NO_TELEMETRY": "1", + "GRAFF_CODEX_URL": f"http://127.0.0.1:{port}/backend-api/codex/responses", + "GRAFF_SERVER_COMPACT": "0", + } + ambient = tuple( + key + for key in os.environ + if (key.startswith("GRAFF_") or key.startswith("CODEX_") or key == "NO_COLOR") + and key not in env + ) + return env, ambient + + +def _new_trace(tmp: str, before: set[str]) -> list[dict]: + trace_dir = Path(tmp, ".graff", "traces") + created = sorted(path for path in trace_dir.iterdir() if path.name not in before) + if len(created) != 1: + raise AssertionError(f"expected one new trace, got {created!r}") + return [json.loads(line) for line in created[0].read_text().splitlines() if line] + + +def _assert_ws_stays_primary(session: base.PtySession) -> None: + cursor = len(session.raw) + session.send_line("/models health") + session.wait_for_literal( + "Codex transport: WebSocket primary with automatic SSE fallback", + start=cursor, + ) + session.wait_for_prompt(start=cursor) + + +def _exit(session: base.PtySession, label: str) -> None: + session.send_key("ctrl-d") + result = session.read_until_exit(5.0) + if result.timed_out or result.exit_code != 0: + raise SystemExit( + f"{label}: REPL did not exit cleanly: " + f"exit={result.exit_code} timed_out={result.timed_out}" + ) + + +def run_generic_error_scenario( + tmp: str, codex_home: str, port: int, mock: CodexMock +) -> None: + """A deterministic API error returns once with bounded safe diagnostics.""" + env, ambient = _env(tmp, codex_home, port) + trace_dir = Path(tmp, ".graff", "traces") + before = {path.name for path in trace_dir.iterdir()} if trace_dir.is_dir() else set() + with base.PtySession( + base.GRAFF, + ["--model", "codex", "--no-telemetry"], + cwd=tmp, + env=env, + unset_env=ambient, + timeout=20.0, + ) as session: + session.wait_for_prompt() + cursor = len(session.raw) + session.send_line("trigger the generic websocket rejection") + expected = f"codex api error [{GENERIC_ERROR_CODE}]: mock bad request" + session.wait_for_literal(expected, start=cursor) + session.wait_for_prompt(start=cursor) + rendered = base.terminal_text(bytes(session.raw[cursor:])) + if GENERIC_ERROR_SECRET in rendered or GENERIC_ERROR_TAIL in rendered: + raise AssertionError(f"generic-error: raw/unbounded diagnostics leaked:\n{rendered}") + if mock.ws_turns != 1 or mock.sse_turns != 0 or mock.ws_connections != 1: + raise AssertionError( + "generic-error: deterministic body was retried/fallen back: " + f"connections={mock.ws_connections} ws={mock.ws_turns} sse={mock.sse_turns}" + ) + _assert_ws_stays_primary(session) + _exit(session, "generic-error") + + events = _new_trace(tmp, before) + diagnostics = [event for event in events if event.get("ev") == "ws_api_error"] + if len(diagnostics) != 1: + raise AssertionError(f"generic-error: expected one ws_api_error trace: {events!r}") + detail = diagnostics[0].get("detail", "") + if ( + GENERIC_ERROR_CODE not in detail + or GENERIC_ERROR_SECRET in detail + or GENERIC_ERROR_TAIL in detail + or len(detail.encode("utf-8")) >= 560 + ): + raise AssertionError(f"generic-error: unsafe trace diagnostic: {detail!r}") + ws_details = [event.get("detail", "") for event in events if event.get("ev") == "ws"] + if any("transport error" in detail or "fallback" in detail for detail in ws_details): + raise AssertionError(f"generic-error: API response burned transport ladder: {ws_details!r}") + + +def run_chain_reanchor_scenario( + tmp: str, codex_home: str, port: int, mock: CodexMock +) -> None: + """A recognized stale chain rebuilds once without requiring `continue`.""" + env, ambient = _env(tmp, codex_home, port) + with base.PtySession( + base.GRAFF, + ["--model", "codex", "--no-telemetry"], + cwd=tmp, + env=env, + unset_env=ambient, + timeout=20.0, + ) as session: + session.wait_for_prompt() + cursor = len(session.raw) + session.send_line("exercise automatic stale-chain recovery") + session.wait_for_literal(CHAIN_FINAL, start=cursor) + session.wait_for_prompt(start=cursor) + requests = mock.recorded_requests() + if mock.ws_turns != 3 or mock.sse_turns != 0 or mock.ws_connections != 2: + raise AssertionError( + "chain-error: expected delta rejection then one fresh WS rebuild: " + f"connections={mock.ws_connections} ws={mock.ws_turns} sse={mock.sse_turns}" + ) + if len(requests) != 3: + raise AssertionError(f"chain-error: expected 3 requests, got {requests!r}") + first, rejected, rebuilt = requests + if ( + first.connection_id != rejected.connection_id + or rejected.connection_id == rebuilt.connection_id + or rejected.body.get("previous_response_id") != "resp_chain_1" + or "previous_response_id" in rebuilt.body + ): + raise AssertionError( + "chain-error: rejected delta was not rebuilt as full input on a fresh WS: " + f"{requests!r}" + ) + rebuilt_types = [ + item.get("type") + for item in rebuilt.body.get("input", []) + if isinstance(item, dict) + ] + if "function_call" not in rebuilt_types or "function_call_output" not in rebuilt_types: + raise AssertionError(f"chain-error: full rebuild lost tool history: {rebuilt.body!r}") + _assert_ws_stays_primary(session) + _exit(session, "chain-error") diff --git a/scripts/codex_ws_mock.py b/scripts/codex_ws_mock.py index 0b97a6d8..d5921e33 100644 --- a/scripts/codex_ws_mock.py +++ b/scripts/codex_ws_mock.py @@ -385,13 +385,20 @@ def _serve_ws( self._log(f"ws <- {etype} ({len(message.payload)}b)") if etype != "response.create": continue - for ev in self._events("ws", connection_id, event, headers): + events = self._events("ws", connection_id, event, headers) + for ev in events: _send_frame( conn, OP_TEXT, json.dumps(ev, separators=(",", ":")).encode("utf-8") ) self._log(f"ws -> {ev['type']}") with self._lock: self.ws_turns += 1 + # Real Codex closes immediately after a terminal type:error frame. + # Keeping the mock open would miss #692's exact classification bug: + # the client waited for another frame, saw EOF, and discarded the + # useful API body as a transport reset. + if any(ev.get("type") == "error" for ev in events): + return def _serve_sse( self, conn: socket.socket, reader: _SockReader, headers: dict[str, str] diff --git a/scripts/codex_ws_test.py b/scripts/codex_ws_test.py index 9c5d7896..8941846c 100644 --- a/scripts/codex_ws_test.py +++ b/scripts/codex_ws_test.py @@ -70,7 +70,19 @@ def user_text(item: object) -> str | None: if not isinstance(item, dict) or item.get("role") != "user": return None content = item.get("content") - return content if isinstance(content, str) else None + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [ + block.get("text") + for block in content + if isinstance(block, dict) + and block.get("type") == "input_text" + and isinstance(block.get("text"), str) + ] + if texts: + return "".join(texts) + return None def last_user_text(request: RecordedRequest) -> str: @@ -398,9 +410,15 @@ def assert_midturn_requests(mock: CodexMock) -> None: # The two synthetic turns are identified by CONTENT, not position, so this # cannot silently pass if their order ever swaps. if not last_user_text(note).startswith("Your context is about to be compacted"): - raise AssertionError( - f"midturn: request 2 is not the #391 note turn: {last_user_text(note)[:120]!r}" - ) + shapes = [ + ( + request.transport, + last_user_text(request)[:80], + str(request.body.get("instructions", ""))[:80], + ) + for request in requests + ] + raise AssertionError(f"midturn: request 2 is not the #391 note turn: {shapes!r}") if not last_user_text(compact).startswith("Summarize this entire conversation"): raise AssertionError( f"midturn: request 3 is not the compaction summary: {last_user_text(compact)[:120]!r}" @@ -689,6 +707,9 @@ def run_midturn_compaction_scenario( timeout=45.0, # compaction legs stream 128 KiB of scripted reasoning; 20s flakes on loaded runners ) as session: session.wait_for_prompt() + # ADR 0042 deliberately paints the prompt before the rest of boot; let + # persisted title/recap settings land before transport counting starts. + session.pump_for(0.5) cursor = len(session.raw) session.send_line(MIDTURN_PROMPT) session.wait_for_literal("wrote a pre-compaction note to self", start=cursor) @@ -733,6 +754,7 @@ def run_transactional_compaction_scenario( timeout=45.0, # compaction legs stream 128 KiB of scripted reasoning; 20s flakes on loaded runners ) as session: session.wait_for_prompt() + session.pump_for(0.5) cursor = len(session.raw) session.send_line(TRANSACTIONAL_PROMPT) session.wait_for_literal( diff --git a/scripts/eval/tier1-manifest.json b/scripts/eval/tier1-manifest.json index 63e68b24..84dd57a9 100644 --- a/scripts/eval/tier1-manifest.json +++ b/scripts/eval/tier1-manifest.json @@ -15,7 +15,7 @@ "src/repl.zig", "TUI/root.zig" ], - "test_count_baseline": 1804, + "test_count_baseline": 1861, "test_count_slack": 25, "required_invariants": [ { diff --git a/scripts/test-pty-codex-ws.py b/scripts/test-pty-codex-ws.py index 07617519..06f72317 100644 --- a/scripts/test-pty-codex-ws.py +++ b/scripts/test-pty-codex-ws.py @@ -6,6 +6,7 @@ import tempfile from codex_ws_mock import CodexMock +import codex_ws_error_test as error_scenario import codex_ws_test as scenario @@ -24,16 +25,15 @@ def main() -> None: fh, ) - # The AI tab-titler (titleTask, src/title.zig) fires one extra quiet SSE - # turn on the first prompt, which would corrupt the transport counters. - # Disable it the same way `/title off` does: the persisted setting in - # the session cwd's .harness/settings.json. + # The AI tab-titler and turn recap each fire an extra quiet SSE turn, + # which would corrupt these transport counters. Disable both through + # the persisted settings this test is specifically not exercising. harness_dir = os.path.join(tmp, ".harness") os.makedirs(harness_dir, exist_ok=True) with open( os.path.join(harness_dir, "settings.json"), "w", encoding="utf-8" ) as fh: - json.dump({"ai_title": False}, fh) + json.dump({"ai_title": False, "session_recap": False}, fh) scenarios = [ ( @@ -100,6 +100,26 @@ def main() -> None: scenario.MIDTURN_CONTEXT_TOKENS = runtime_context scenario.MIDTURN_TOTAL_TOKENS = runtime_context * 9 // 10 + mock = CodexMock(events_for_request=error_scenario.generic_error_events) + port = mock.start() + try: + error_scenario.run_generic_error_scenario(tmp, codex_home, port, mock) + finally: + mock.stop() + print( + "ok generic WS API error: one bounded diagnostic, no transport retry/fallback" + ) + + mock = CodexMock(events_for_request=error_scenario.chain_error_events) + port = mock.start() + try: + error_scenario.run_chain_reanchor_scenario(tmp, codex_home, port, mock) + finally: + mock.stop() + print( + "ok stale WS chain error: automatic full-input re-anchor on one fresh socket" + ) + mock = CodexMock(events_for_request=scenario.midturn_events) port = mock.start() try: diff --git a/scripts/test-session-branching.py b/scripts/test-session-branching.py new file mode 100644 index 00000000..f2124aaf --- /dev/null +++ b/scripts/test-session-branching.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Offline process-level regression for #689 session clone-on-write branching. + +Runs two live line-REPL processes from one baseline against codex_ws_mock.py, +proves their provider histories and durable files stay isolated, then exercises +the original input-buffer `/resume` autosave corruption path. + +Usage: python3 scripts/test-session-branching.py [path/to/graff] +""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import sys +import tempfile +import threading +import time + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from codex_ws_mock import CodexMock, RecordedRequest # noqa: E402 + +_arg = sys.argv[1] if len(sys.argv) > 1 else "zig-out/bin/graff" +GRAFF = str(pathlib.Path(_arg).resolve()) +BASE = "BRANCH_BASELINE_689" +A = "BRANCH_ONLY_A_689" +A_CHECK = "REOPEN_BRANCH_A_689" +B = "BRANCH_ONLY_B_689" +B2 = "BRANCH_B_AFTER_A_EXIT_689" +B_CHECK = "REOPEN_BRANCH_B_689" +OWNED = "RESUME_INPUT_BUFFER_OWNERSHIP_689" + + +def reply(text: str, ordinal: int) -> list[dict]: + return [ + { + "type": "response.output_item.done", + "item": { + "type": "message", + "id": f"msg_{ordinal}", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": text, "annotations": []}], + }, + }, + { + "type": "response.completed", + "response": { + "id": f"resp_{ordinal}", + "usage": { + "input_tokens": 100, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 10, + "total_tokens": 110, + }, + }, + }, + ] + + +def events(request: RecordedRequest) -> list[dict]: + body = json.dumps(request.body.get("input", [])) + if A in body and B not in body: + time.sleep(0.15) + elif B in body and A not in body: + time.sleep(0.4) + seen = [token for token in (BASE, A, B, B2, A_CHECK, B_CHECK, OWNED) if token in body] + return reply("SEEN " + ",".join(seen), request.ordinal) + + +def environment(workspace: pathlib.Path, port: int) -> dict[str, str]: + codex_home = workspace / "codex-home" + codex_home.mkdir() + (codex_home / "auth.json").write_text( + json.dumps({"tokens": {"access_token": "mock", "account_id": "acct"}}), + encoding="utf-8", + ) + harness = workspace / ".harness" + harness.mkdir() + (harness / "settings.json").write_text( + json.dumps({"ai_title": False, "skills": {"codedbpro": False}}), + encoding="utf-8", + ) + empty_mcp = workspace / "empty-mcp.json" + empty_mcp.write_text('{"mcpServers":{}}', encoding="utf-8") + env = { + key: value + for key, value in os.environ.items() + if not key.startswith("GRAFF_") and not key.startswith("CODEX_") + } + env.update( + { + "HOME": str(workspace), + "CODEX_HOME": str(codex_home), + "GRAFF_CODEX_URL": f"http://127.0.0.1:{port}/backend-api/codex/responses", + "GRAFF_CODEX_WS": "off", + "GRAFF_FLEET": "off", + "GRAFF_NO_TELEMETRY": "1", + "GRAFF_LEARN_AUTO": "off", + "GRAFF_MCP_CONFIG": str(empty_mcp), + "NO_COLOR": "1", + } + ) + return env + + +def pump(stream, target: list[str]) -> None: + for line in stream: + target.append(line) + + +def spawn(cmd: list[str], workspace: pathlib.Path, env: dict[str, str]): + proc = subprocess.Popen( + cmd, + cwd=workspace, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + out: list[str] = [] + err: list[str] = [] + threading.Thread(target=pump, args=(proc.stdout, out), daemon=True).start() + threading.Thread(target=pump, args=(proc.stderr, err), daemon=True).start() + return proc, out, err + + +def wait_for(proc: subprocess.Popen, output: list[str], needle: str, timeout: float = 30) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + if needle in "".join(output): + return + if proc.poll() is not None: + raise AssertionError( + f"process exited {proc.returncode} waiting for {needle!r}:\n{''.join(output)[-1500:]}" + ) + time.sleep(0.03) + raise AssertionError(f"timed out waiting for {needle!r}:\n{''.join(output)[-1500:]}") + + +def messages(path: pathlib.Path) -> str: + return json.dumps(json.loads(path.read_text(encoding="utf-8"))["messages"]) + + +def request_for(mock: CodexMock, marker: str) -> str: + matches = [json.dumps(req.body.get("input", [])) for req in mock.requests if marker in json.dumps(req.body)] + if not matches: + raise AssertionError(f"mock saw no request containing {marker}") + return matches[-1] + + +def close(proc: subprocess.Popen) -> None: + assert proc.stdin is not None + proc.stdin.close() + proc.wait(timeout=30) + if proc.returncode != 0: + raise AssertionError(f"graff exited {proc.returncode}") + + +def main() -> None: + mock = CodexMock(events_for_request=events) + port = mock.start() + try: + with tempfile.TemporaryDirectory(prefix="graff-session-branching-") as tmp: + workspace = pathlib.Path(tmp) + env = environment(workspace, port) + cmd = [GRAFF, "--model", "codex", "--yolo", "--no-telemetry"] + + seed = subprocess.run( + cmd + ["--resume", "baseline"], + cwd=workspace, + env=env, + input=BASE + "\n", + text=True, + capture_output=True, + timeout=30, + ) + assert seed.returncode == 0, seed.stderr + sessions = workspace / ".graff" / "sessions" + source = sessions / "baseline.session.json" + assert BASE in messages(source) + + pa, oa, ea = spawn(cmd + ["--resume", "baseline", "--branch", "branch-a"], workspace, env) + pb, ob, eb = spawn(cmd + ["--resume", "baseline", "--branch", "branch-b"], workspace, env) + wait_for(pa, oa, "branched baseline.session.json → branch-a.session.json") + wait_for(pb, ob, "branched baseline.session.json → branch-b.session.json") + assert pa.poll() is None and pb.poll() is None + + assert pa.stdin is not None and pb.stdin is not None + pa.stdin.write(A + "\n") + pa.stdin.flush() + pb.stdin.write(B + "\n") + pb.stdin.flush() + wait_for(pa, oa, f"SEEN {BASE},{A}") + wait_for(pb, ob, f"SEEN {BASE},{B}") + time.sleep(0.5) + + branch_a = sessions / "branch-a.session.json" + branch_b = sessions / "branch-b.session.json" + source_body = messages(source) + a_body = messages(branch_a) + b_body = messages(branch_b) + assert A not in source_body and B not in source_body + assert BASE in a_body and A in a_body and B not in a_body + assert BASE in b_body and B in b_body and A not in b_body + source_json = json.loads(source.read_text()) + branch_a_json = json.loads(branch_a.read_text()) + branch_b_json = json.loads(branch_b.read_text()) + assert branch_a_json["parent"] == "baseline" + assert branch_b_json["parent"] == "baseline" + assert len({source_json["cache_key"], branch_a_json["cache_key"], branch_b_json["cache_key"]}) == 3 + + close(pa) + assert pb.poll() is None, "closing A terminated B" + pb.stdin.write(B2 + "\n") + pb.stdin.flush() + wait_for(pb, ob, f"SEEN {BASE},{B},{B2}") + close(pb) + + for name, marker in (("branch-a", A_CHECK), ("branch-b", B_CHECK)): + reopened = subprocess.run( + cmd + ["--resume", name, marker], + cwd=workspace, + env=env, + text=True, + capture_output=True, + timeout=30, + ) + assert reopened.returncode == 0, reopened.stderr + a_request = request_for(mock, A_CHECK) + b_request = request_for(mock, B_CHECK) + assert BASE in a_request and A in a_request and B not in a_request and B2 not in a_request + assert BASE in b_request and B in b_request and B2 in b_request and A not in b_request + + duplicate = subprocess.run( + cmd + ["--resume", "baseline", "--branch", "branch-a"], + cwd=workspace, + env=env, + text=True, + capture_output=True, + timeout=15, + ) + assert duplicate.returncode != 0 + assert "BranchAlreadyExists" in duplicate.stderr or "already exists" in duplicate.stderr + + race = [spawn(cmd + ["--resume", "baseline", "--branch", "race-dest"], workspace, env) for _ in range(2)] + deadline = time.time() + 30 + while time.time() < deadline: + live = [item for item in race if item[0].poll() is None] + done = [item for item in race if item[0].poll() is not None] + if len(live) == 1 and len(done) == 1 and "branched baseline.session.json → race-dest.session.json" in "".join(live[0][1]): + break + time.sleep(0.03) + else: + raise AssertionError(f"same-destination race was not exclusive: {[(p.poll(), ''.join(o), ''.join(e)) for p, o, e in race]}") + winner = next(item for item in race if item[0].poll() is None) + loser = next(item for item in race if item[0].poll() is not None) + assert loser[0].returncode != 0 + assert "BranchAlreadyExists" in "".join(loser[2]) or "already exists" in "".join(loser[2]) + close(winner[0]) + + owner, owner_out, owner_err = spawn(cmd, workspace, env) + assert owner.stdin is not None + owner.stdin.write("/resume baseline\n") + owner.stdin.flush() + wait_for(owner, owner_out, "resumed baseline.session.json") + owner.stdin.write(OWNED + "\n") + owner.stdin.flush() + wait_for(owner, owner_out, f"SEEN {BASE},{OWNED}") + close(owner) + assert OWNED in messages(source) + + files = sorted(path.name for path in sessions.glob("*.session.json")) + assert "baseline.session.json" in files + assert "branch-a.session.json" in files + assert "branch-b.session.json" in files + assert all("\n" not in name and "\r" not in name for name in files), files + for transcript in sessions.glob("*.transcript.jsonl"): + for line in transcript.read_text(encoding="utf-8").splitlines(): + json.loads(line) + + assert not ea, "branch A stderr: " + "".join(ea) + assert not eb, "branch B stderr: " + "".join(eb) + assert not owner_err, "ownership stderr: " + "".join(owner_err) + print("session branching: source immutable, branches isolated/reopenable, ownership and destination claims stable") + finally: + mock.stop() + + +if __name__ == "__main__": + main() diff --git a/src/agent.zig b/src/agent.zig index 8f681bc7..5dfe4917 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -167,6 +167,7 @@ pub const Agent = struct { loop_deadline_ms: ?i64 = null, // the running /loop's wall-clock deadline (goal_pacing.LoopClock); read by the subagent spawn path so a child inherits it. Run-local: never saved, cleared on stop/steer history_rewrites: u32 = 0, // bumped by compact()/emergencyTrim; state pasted into the dead history (e.g. the /loop checklist copy) must be re-carried (#318) session_name: []const u8 = "last", // autosave/resume target (.session.json) + session_parent: ?[]const u8 = null, // clone-on-write ancestry: this session branched from session_title: ?[]const u8 = null, // human-readable title/rename metadata sys_base: []const u8 = "", // #381: the last BASE handed to prompts.setSystemPrompts, WITHOUT the playbook block — what a mid-session constraint re-composes from (playbook_glue.refreshRoot) sys_strict: []const u8 = prompts.main_system_prompt_strict, @@ -594,7 +595,3 @@ test { _ = @import("effort_route.zig"); try agent_tests.lazyRootTools(Agent); } - -test "say: an over-long worker line still ends its row (#tui-tick)" { - try agent_tests.workerLineAlwaysEndsRow(Agent); -} diff --git a/src/agent_empty_completion.zig b/src/agent_empty_completion.zig index 987b4402..8e5743f4 100644 --- a/src/agent_empty_completion.zig +++ b/src/agent_empty_completion.zig @@ -1,14 +1,18 @@ -//! Degenerate-completion policy for Agent.runTurn. A provider completion can -//! arrive with no text, no tool calls, and no non-"stop" finish reason — -//! observed live on the chat wire as `content: null`, no `tool_calls` -//! (mid-task, right after a successful write_file). The step functions then -//! return "" and the turn ends silently: the session sits there looking dead. -//! Instead, rewind the history the step function just appended and re-ask, -//! bounded per turn so a wedged provider can't spin up unbounded spend. +//! Degenerate-completion policy for Agent.runTurn. +//! +//! 1. Empty / whitespace-only completions (no tool calls) used to end the +//! turn silently. Rewind and re-ask, bounded. +//! 2. Lean `-p` text-only first completions (DeepSeek flash SWE): the model +//! describes a patch and stops. That is not done — bounce once with a +//! user note (ADR 0052). Do not steal Pi's four-tool catalog (ADR 0024 / 0047). +//! //! Split from agent.zig (600-line goal); wired only in runTurn. const std = @import("std"); const Agent = @import("agent.zig").Agent; +const main_mod = @import("main.zig"); +const no_local_tools = @import("no_local_tools.zig"); +const messages = @import("messages.zig"); /// Retries allowed per turn for consecutive degenerate completions. pub const max_consecutive: u8 = 2; @@ -21,17 +25,30 @@ pub fn shouldRetry(final_text: []const u8, retries: u8) bool { return std.mem.trim(u8, final_text, " \t\r\n").len == 0; } -/// Handle a degenerate completion inside runTurn: spend one bounded retry by -/// rewinding the history the step function just appended (clamped — an -/// in-request compaction may already have shrunk it) and re-opening the WS -/// chain, whose watermark is keyed to the dropped messages. Returns true when -/// the caller should `continue` the loop. +/// Lean `-p` described a fix and never called a tool. One bounce. +pub const bounce_note = "You described a change but did not call any tool. Inspect and edit the files with read_file / edit_file / write_file; do not claim the tree is already updated."; + +pub fn shouldBounce(unattended: bool, lean: bool, text_only: bool, review: bool, sub: bool, tool_calls: u64, model_calls: u64, final_text: []const u8) bool { + if (!unattended or !lean or text_only or review or sub) return false; + if (tool_calls != 0 or model_calls != 1) return false; + return std.mem.trim(u8, final_text, " \t\r\n").len > 0; +} + +/// Handle a degenerate completion inside runTurn. Returns true when the +/// caller should `continue` the loop. pub fn handle(self: *Agent, final_text: []const u8, hist_len: usize) !bool { - if (!shouldRetry(final_text, self.empty_completion_retries)) return false; - self.empty_completion_retries += 1; - self.closeCodexWs(); - self.messages.shrinkRetainingCapacity(@min(hist_len, self.messages.items.len)); - try self.say("[model returned an empty completion — retrying ({d}/{d})]\n", .{ self.empty_completion_retries, max_consecutive }); + if (shouldRetry(final_text, self.empty_completion_retries)) { + self.empty_completion_retries += 1; + self.closeCodexWs(); + self.messages.shrinkRetainingCapacity(@min(hist_len, self.messages.items.len)); + try self.say("[model returned an empty completion — retrying ({d}/{d})]\n", .{ self.empty_completion_retries, max_consecutive }); + return true; + } + if (!shouldBounce(main_mod.unattended, no_local_tools.lean, self.text_only, self.review_mode, self.sub, self.tool_calls_this_turn, self.model_calls_this_turn, final_text)) + return false; + try self.messages.append(try messages.textMessage(self.arena, "user", bounce_note)); + try self.say("[described a change with no tool call — asking once more]\n", .{}); + if (self.tracer) |tr| tr.note("fake_done", "lean -p text-only; bounced"); return true; } @@ -47,3 +64,20 @@ test "whitespace-only completions are degenerate, real text never is" { try std.testing.expect(!shouldRetry("done", 0)); try std.testing.expect(!shouldRetry("<|eos|>", 0)); } + +test "lean -p text-only first completion bounces once" { + try std.testing.expect(shouldBounce(true, true, false, false, false, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, false, false, false, 0, 2, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, false, false, false, 1, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(false, true, false, false, false, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, false, false, false, false, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, true, false, false, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, false, true, false, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, false, false, true, 0, 1, "I fixed validated.py")); + try std.testing.expect(!shouldBounce(true, true, false, false, false, 0, 1, " ")); +} + +test "bounce note names the file tools" { + try std.testing.expect(std.mem.indexOf(u8, bounce_note, "edit_file") != null); + try std.testing.expect(std.mem.indexOf(u8, bounce_note, "write_file") != null); +} diff --git a/src/agent_gateway_retry.zig b/src/agent_gateway_retry.zig index a6ab0f04..71eb32ae 100644 --- a/src/agent_gateway_retry.zig +++ b/src/agent_gateway_retry.zig @@ -10,6 +10,12 @@ //! history it stays a fail-fast 400, exactly as before. //! 2. Retry trace notes carry the agent label, so a 5xx can be attributed to //! the subagent that drew it without ms-arithmetic across api spans. +//! 3. A short generic `api_error` / "Internal Server Error" / empty envelope +//! (the DeepSeek flash `-j 6` follow-up flake) is retried bounded. The +//! same 110-byte / ~450ms follow-up also arrives as `invalid_request_error` +//! / "Body must be valid JSON" (our stringify just succeeded on call 1). +//! That phrase is a flake; auth / quota / a real invalid prompt stay +//! fail-fast. const std = @import("std"); const Agent = @import("agent.zig").Agent; @@ -72,14 +78,70 @@ pub fn noteFlake(self: *Agent, state: *GatewayRetryState, err: anyerror) void { if (err == error.Timeout) state.transport_timeouts += 1; } -/// One gate for the envelope-fatal paths: the pre-existing transient -/// server-overload retry first (unchanged semantics), then the bounded -/// gateway-artifact retry when this request already endured >=2 timeouts. +/// One gate for the envelope-fatal paths: overload first, then a short +/// Codegraff follow-up flake (ADR 0053 DeepSeek `-j 6` 110-byte / ~450ms) +/// `api_error`), then the timeout-gated body-parse retry. pub fn afterServerErrorOrParseReject(self: *Agent, etype: []const u8, code: ?[]const u8, msg: []const u8, server_retries: *usize, state: *GatewayRetryState) !bool { if (try policy.retryTransientServerError(self, etype, code, msg, server_retries)) return true; + if (try retryShortGatewayFlake(self, etype, code, msg, server_retries)) return true; return retryBodyParseAfterTimeouts(self, msg, state); } +pub const max_short_flake_retries: usize = 2; + +/// Tiny / generic envelopes that are not a real client bug. The DeepSeek +/// SWE `-j 6` follow-up was ~110 bytes, ~450 ms, `is_error`, no +/// "overloaded" / "server_error" needle — isolated serial retry passed. +/// Do not treat invalid_request / auth / quota as a flake. +pub fn isShortGatewayFlake(etype: []const u8, code: ?[]const u8, msg: []const u8) bool { + // Gateway 110-byte follow-up. etype is often invalid_request_error, which + // would otherwise hard-fail on the "invalid" needle. Auth/quota still die. + if (isBodyParseRejection(msg)) return true; + const hard = [_][]const u8{ "invalid", "authentication", "unauthorized", "insufficient", "quota", "permission", "tool_choice", "not found" }; + for (hard) |n| { + if (util.indexOfIgnoreCase(etype, n) != null) return false; + if (util.indexOfIgnoreCase(msg, n) != null) return false; + if (code) |c| if (util.indexOfIgnoreCase(c, n) != null) return false; + } + const flakes = [_][]const u8{ "internal", "try again", "temporarily", "unavailable", "bad gateway", "upstream", "capacity", "unknown error", "something went wrong", "an error occurred" }; + for (flakes) |n| { + if (util.indexOfIgnoreCase(etype, n) != null) return true; + if (util.indexOfIgnoreCase(msg, n) != null) return true; + if (code) |c| if (util.indexOfIgnoreCase(c, n) != null) return true; + } + const generic = etype.len == 0 or std.mem.eql(u8, etype, "error") or std.mem.eql(u8, etype, "api_error"); + return generic and std.mem.trim(u8, msg, " \t\r\n").len == 0; +} + +/// Unparseable body that is keep-alive comments or a tiny truncated +/// payload (the 110-byte follow-up). Bounded. Real JSON error envelopes +/// do not reach this — they go through `afterServerErrorOrParseReject`. +pub fn retryDegenerateBody(self: *Agent, body: []const u8, retries: *usize) !bool { + const tiny = body.len > 0 and body.len <= 256; + if (!policy.sseKeepAliveOnly(body) and !tiny) return false; + if (retries.* >= max_short_flake_retries) return false; + retries.* += 1; + self.partial_text.clearRetainingCapacity(); + const delay_ms = RetryPlan.delayMs(true, retries.* - 1); + const what: []const u8 = if (policy.sseKeepAliveOnly(body)) "keep-alive only, no tokens" else "truncated gateway body"; + try self.say("[provider queued the request ({s}) — retrying in {d}s ({d}/{d})]\n", .{ what, delay_ms / 1000, retries.*, max_short_flake_retries }); + if (self.tracer) |tr| tr.note("retry", what); + self.sleepInterruptible(delay_ms) catch return error.Interrupted; + return true; +} + +fn retryShortGatewayFlake(self: *Agent, etype: []const u8, code: ?[]const u8, msg: []const u8, retries: *usize) !bool { + if (!isShortGatewayFlake(etype, code, msg)) return false; + if (retries.* >= max_short_flake_retries) return false; + retries.* += 1; + self.partial_text.clearRetainingCapacity(); + const delay_ms = RetryPlan.delayMs(true, retries.* - 1); + try self.say("[gateway flake — retrying in {d}s ({d}/{d})]\n", .{ delay_ms / 1000, retries.*, max_short_flake_retries }); + if (self.tracer) |tr| tr.note("retry", "short gateway flake"); + self.sleepInterruptible(delay_ms) catch return error.Interrupted; + return true; +} + /// The gate on the Agent: announce, trace, back off (1·2s — the gateway just /// answered, give it a beat), clear partial text for a fresh re-stream, and /// tell the caller to `continue`. Esc during the backoff still propagates. @@ -117,3 +179,19 @@ test "shouldRetryBodyParseAfterTimeouts (#gateway-artifact): timeout history gat // an unrelated message never retries, however many timeouts preceded it try std.testing.expect(!shouldRetryBodyParseAfterTimeouts("quota exceeded", 5, 0)); } + +test "isShortGatewayFlake: internal/empty api_error retry; invalid/auth/quota do not" { + try std.testing.expect(isShortGatewayFlake("api_error", null, "Internal Server Error")); + try std.testing.expect(isShortGatewayFlake("api_error", null, "")); + try std.testing.expect(isShortGatewayFlake("error", null, " ")); + try std.testing.expect(isShortGatewayFlake("", null, "unknown error")); + try std.testing.expect(isShortGatewayFlake("", null, "upstream connect error")); + try std.testing.expect(!isShortGatewayFlake("invalid_request_error", null, "invalid prompt")); + try std.testing.expect(!isShortGatewayFlake("api_error", null, "invalid tool_choice")); + try std.testing.expect(!isShortGatewayFlake("authentication_error", null, "invalid api key")); + try std.testing.expect(!isShortGatewayFlake("insufficient_quota", null, "You exceeded your current quota")); + try std.testing.expect(!isShortGatewayFlake("api_error", null, "model not found")); + try std.testing.expect(isShortGatewayFlake("invalid_request_error", null, "Body must be valid JSON")); + try std.testing.expect(isShortGatewayFlake("api_error", null, "Malformed JSON in request body")); + try std.testing.expect(!isShortGatewayFlake("invalid_request_error", null, "invalid prompt")); +} diff --git a/src/agent_request.zig b/src/agent_request.zig index d279e2ed..eee06c16 100644 --- a/src/agent_request.zig +++ b/src/agent_request.zig @@ -30,22 +30,6 @@ const run_budget_mod = @import("run_budget.zig"); const wire_messages = @import("messages.zig"); const policy = @import("agent_request_policy.zig"); -const max_server_retries: usize = 3; // bounded retries for a keep-alive-only response body - -/// A response body of only SSE comment lines (`: OPENROUTER PROCESSING` …) -/// means the gateway queued us and never produced tokens — back off and re-ask -/// like a 5xx instead of dying on an "unparseable" JSON parse. -fn retryKeepAliveOnlyResponse(self: *Agent, body: []const u8, retries: *usize) !bool { - if (!policy.sseKeepAliveOnly(body)) return false; - if (retries.* >= max_server_retries) return false; - retries.* += 1; - self.partial_text.clearRetainingCapacity(); - const delay_ms = RetryPlan.delayMs(true, retries.* - 1); // 1·2·4s - try self.say("[provider queued the request (keep-alive only, no tokens) — retrying in {d}s ({d}/{d})]\n", .{ delay_ms / 1000, retries.*, max_server_retries }); - self.sleepInterruptible(delay_ms) catch return error.Interrupted; - return true; -} - const overflow = @import("agent_overflow.zig"); const errorCode = policy.errorCode; const isQuotaExceeded = policy.isQuotaExceeded; @@ -82,27 +66,7 @@ pub const buildBody = @import("agent_request_body.zig").buildBody; const codex_chain = @import("codex_chain.zig"); const req_stats = @import("req_stats.zig"); // GRAFF_REQ_STATS anatomy (session_settings arms req_stats.g_armed) - -/// Keep the normal request hot path allocation-free while avoiding a permanent -/// RSS high-water mark after one anomalously large stream. Small scratch arenas -/// retain their pages for the next request; large ones return all pages to the -/// backing allocator. History never lives here, so either reset mode is safe at -/// the start of the next request. -const scratch_retain_limit = 4 * 1024 * 1024; - -fn resetRequestScratch(scratch: *std.heap.ArenaAllocator) void { - if (scratch.queryCapacity() > scratch_retain_limit) { - _ = scratch.reset(.free_all); - } else { - _ = scratch.reset(.retain_capacity); - } -} - -/// Detached recaps are cosmetic: keep recovered transport details in the trace, -/// but do not surface them as raw worker chatter in the normal REPL. -fn showRecoveredTransportRetry(kind: run_budget_mod.CallKind) bool { - return kind != .recap; -} +const scratch = @import("agent_request_scratch.zig"); /// #390 — appended once, on the run's final admitted model call, right where /// the tools disappear, so the model knows WHY and lands instead of retrying. @@ -113,9 +77,9 @@ pub const landing_note = "results beat dying mid-tool-call."; pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { - // Startup paints the prompt while CA loading continues. The root turn and - // title task rendezvous here, then issue their requests concurrently. + // Root and title requests rendezvous after launch-time CA loading. http.waitForClientReady(self.io); + if (http.takeCaWarmFailure()) if (self.tracer) |tr| tr.note("ca_prewarm_failed", "CA bundle rescan failed; request will use lazy TLS initialization"); if (self.registry) |reg| { if (@import("mcp_boot.zig").joinBeforeRequest(reg)) { self.invalidateRootTools(); @@ -142,6 +106,9 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // budget can never pay for — which is how the audit smoke died narrating. // compaction/title requests pass tools=null already and skip this whole. var tools = tools_in; + if (self.tracer) |tr| { + if (tools) |t| if (t.len == 0) tr.note("tools", "empty catalog at request time (#695)"); + } if (self.run_budget) |b| if (budget_permit) |p| { if (b.max_model_calls != 0 and p.call_number == b.max_model_calls and tools != null) { tools = null; @@ -160,7 +127,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // for this request. Safe: all scratch data is // consumed before the next request(); messages/todos/prompts live on the // session arena. - if (self.scratch_arena) |sa| resetRequestScratch(sa); + if (self.scratch_arena) |sa| scratch.reset(sa); // #148/#402: a login-sourced OAuth token expires mid-session and is minted // only at startup; pick up whatever is currently on disk before the call, so // a long session — or a subagent that inherited the token — never 401s over @@ -245,6 +212,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // error.ApiError so the REPL returns to the prompt, never crashes. const resp_body = blk: { var attempt: usize = 0; + var retry_limit: ?usize = null; while (true) : (attempt += 1) { var conv_buf: [96]u8 = undefined; const conv = http_headers.promptCacheKey(self.io, self.label, self, &conv_buf); @@ -328,7 +296,8 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // (1s·2ⁿ, capped at 8s; Esc cancels) and allow a few // more attempts than a plain transport flake gets. const throttled = err == error.RateLimited or err == error.ServerError; - const max_attempts: usize = RetryPlan.maxAttempts(throttled); + const max_attempts = retry_limit orelse RetryPlan.maxAttempts(throttled); + retry_limit = max_attempts; // #opencode-parity: a 429 that's a billing/quota cap (not // transient throttling) won't clear by retrying — fail fast so // cross-provider /fallback can take over, instead of burning all @@ -347,7 +316,11 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { if (self.tracer) |tr| tr.api(self.label, self.sub, self.provider.model, 0, body.len, 0, 0, 0, true); return error.ApiError; } - if (attempt < max_attempts) { + if (err == error.TlsRequestConstructionFailed or err == error.TlsRequestConstructionCaWarmFailed) if (self.tracer) |tr| tr.note( + "tls_request_construction", + if (err == error.TlsRequestConstructionCaWarmFailed) "rotated shared HTTP client generation; replacement CA prewarm failed" else "rotated shared HTTP client generation", + ); + if (attempt + 1 < max_attempts) { if (throttled) { // #retry-after: prefer the provider's Retry-After // (429/503) over our computed backoff, capped — like @@ -363,14 +336,11 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { policy.noteRetry(self, what); self.sleepInterruptible(delay_ms) catch return error.Interrupted; } else { - // Transport flake (HttpConnectionClosing, a reset, - // a truncated TLS read): back off before a fresh - // connection. Rapid-fire retries against a - // just-closed keep-alive almost always re-fail - // (#86). 250ms·2ⁿ, capped at 4s over 6 tries; Esc cancels. + // Transport flakes back off; rapid retries against a + // just-closed keep-alive re-fail (#86). Cap: 4s/6 tries. const delay_ms = RetryPlan.delayMs(throttled, attempt); @import("turn_chrome.zig").emitRetryNotice(self.io, @errorName(err), attempt + 1, max_attempts); - if (showRecoveredTransportRetry(self.call_kind)) + if (scratch.showRecoveredTransportRetry(self.call_kind)) try self.say("[network error: {t} — retrying in {d}ms ({d}/{d})]\n", .{ err, delay_ms, attempt + 1, max_attempts }); // Same trace breadcrumb the 429/5xx branch leaves: a // transport-flake retry is otherwise invisible in the @@ -381,12 +351,9 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { continue; } try self.say("[request failed: {t} — giving up this turn]\n", .{err}); - // Network give-up is its own error kind: the ApiError - // handler's last_api_error would otherwise be an API - // envelope, stale or null on a pure transport failure — - // record the real reason so the failed turn's --json error - // event and trajectory node preserve it (#86). - self.last_api_error = std.fmt.allocPrint(self.arena, "network error: {s} (gave up after {d} attempts)", .{ @errorName(err), max_attempts }) catch null; + // Preserve whether this was provider throttling or a transport failure in the failed turn's JSON/trajectory (#86). + const failure_kind = if (err == error.RateLimited) "rate limited (429)" else if (err == error.ServerError) "server error (5xx)" else "network error"; + self.last_api_error = std.fmt.allocPrint(self.arena, "{s}: {s} (gave up after {d} attempts)", .{ failure_kind, @errorName(err), max_attempts }) catch null; self.last_request_write_failed = std.mem.eql(u8, @errorName(err), "WriteFailed"); if (telemetry.g_telem) |t| t.errorEvent("net", @errorName(err)); if (self.tracer) |tr| tr.api(self.label, self.sub, self.provider.model, 0, body.len, 0, 0, 0, true); @@ -398,7 +365,6 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { const ms: i64 = t0.untilNow(self.io, .awake).toMilliseconds(); if (!live) self.traceFirstToken(); - // object — pull the final `response` out of it (or an error). // object — pull the final `response` out of it (or an error). if (self.provider.kind == .responses) { const r = self.parseResponses(resp_body) catch { @@ -426,13 +392,14 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { }, .err => |failure| { const msg = failure.message; - // (#codex-ws) Belt-and-braces mirror of openai/codex: server - // rejected previous_response_id (stale WS session it no - // longer recognizes) — close it and retry once with full - // input. Gated on codex_prev_id != null (a delta was - // actually sent); it's null after the retry, so this can't - // loop for this request. - if (self.codex_prev_id != null and codex_chain.shouldDropChain(msg, failure.code)) { + const had_chain = self.codex_prev_id != null; + const ws_error_frame = if (self.codex_ws) |c| c.dead else false; + const diagnostic = try responses.failureDiagnostic(self.arena, self.provider.id, failure); + if (ws_error_frame) { + self.closeCodexWs(); + if (self.tracer) |tr| tr.note("ws_api_error", diagnostic); + } + if (had_chain and codex_chain.shouldDropChain(msg, failure.code)) { self.closeCodexWs(); if (self.tracer) |tr| tr.note("ws", "server dropped previous_response_id — re-anchoring with full input"); continue :rebuild; @@ -469,7 +436,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // overload retry path as SSE and JSON error envelopes. if (try policy.afterServerErrorOrParseReject(self, "", failure.code, msg, &server_retries, &gw_retry)) continue :rebuild; if (self.tracer) |tr| tr.api(self.label, self.sub, self.provider.model, ms, body.len, resp_body.len, 0, 0, true); - try self.sayApiError("{s} api error: {s}", .{ self.provider.id, msg }); + try self.sayApiError("{s}", .{diagnostic}); return error.ApiError; }, } @@ -504,7 +471,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { const resp = std.json.parseFromSliceLeaky(Value, self.messageMutationAlloc(), resp_body, .{ .allocate = .alloc_always, }) catch { - if (try retryKeepAliveOnlyResponse(self, resp_body, &server_retries)) continue; + if (try scratch.retryKeepAliveOnly(self, resp_body, &server_retries)) continue; try self.sayApiError("unparseable response: {s}", .{resp_body[0..@min(resp_body.len, 400)]}); return error.ApiError; }; @@ -576,25 +543,3 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { return root; } } - -test "recovered recap transport retries stay out of normal REPL output" { - try std.testing.expect(!showRecoveredTransportRetry(.recap)); - try std.testing.expect(showRecoveredTransportRetry(.root)); - try std.testing.expect(showRecoveredTransportRetry(.child)); -} - -test "request scratch retains normal capacity but releases an oversized spike" { - var scratch = std.heap.ArenaAllocator.init(std.testing.allocator); - defer scratch.deinit(); - - _ = try scratch.allocator().alloc(u8, 1024); - const normal_capacity = scratch.queryCapacity(); - try std.testing.expect(normal_capacity > 0); - resetRequestScratch(&scratch); - try std.testing.expectEqual(normal_capacity, scratch.queryCapacity()); - - _ = try scratch.allocator().alloc(u8, scratch_retain_limit + 1); - try std.testing.expect(scratch.queryCapacity() > scratch_retain_limit); - resetRequestScratch(&scratch); - try std.testing.expectEqual(@as(usize, 0), scratch.queryCapacity()); -} diff --git a/src/agent_request_body.zig b/src/agent_request_body.zig index 04fc5e6c..091d837c 100644 --- a/src/agent_request_body.zig +++ b/src/agent_request_body.zig @@ -22,7 +22,9 @@ pub fn responsesOutputLimit(self: *const Agent) u32 { return self.responses_output_limit orelse max_tokens; } -pub fn buildBody(self: *Agent, tools: ?[]const u8, force_tool: bool, stream: bool, stream_usage: bool) ![]u8 { +pub fn buildBody(self: *Agent, tools_in: ?[]const u8, force_tool: bool, stream: bool, stream_usage: bool) ![]u8 { + // #695: an EMPTY catalog string means "omit tools" — `""` serialized the malformed `"tools":,` that 400s every provider (0.0.280). + const tools: ?[]const u8 = if (tools_in) |t| (if (t.len == 0) null else t) else null; var aw: Io.Writer.Allocating = .init(self.gpa); errdefer aw.deinit(); var s: std.json.Stringify = .{ .writer = &aw.writer }; @@ -174,7 +176,7 @@ pub fn buildBody(self: *Agent, tools: ?[]const u8, force_tool: bool, stream: boo try s.objectField("prompt_cache_key"); try s.write(http_headers.requestCacheKey(self.io, self.label, self, self.provider.id, &ckbuf)); // reasoning_effort (codegraff/deepseek/zai) + Z.AI thinking + Vercel reasoning.effort. - try @import("zai_wire.zig").writeChatExtras(&s, self.provider.id, self.sendReasoningEffort(), @import("effort_route.zig").wireEffort(self.provider.model, @tagName(self.reasoning))); + try @import("zai_wire.zig").writeChatExtras(&s, self.provider.id, self.provider.model, self.sendReasoningEffort(), @import("effort_route.zig").wireEffort(self.provider.model, @tagName(self.reasoning))); // --output-schema: structured outputs (xAI docs' response_format). // A provider that rejected json_schema (#543, deepseek) degrades // dsh-style: the tools-off formatting turn carries the schema as a diff --git a/src/agent_request_body_responses.zig b/src/agent_request_body_responses.zig index 3c91f448..cae659cc 100644 --- a/src/agent_request_body_responses.zig +++ b/src/agent_request_body_responses.zig @@ -318,6 +318,43 @@ fn testAgentFor(arena: std.mem.Allocator, id: []const u8, kind: @import("provide }; } +// #695: the wide-native RLM showcase invalidated `tools_responses` and never +// rebuilt it, and the next body serialized `"tools":,` — an HTTP 400 on +// every provider. The serialization guard in buildBody turns an empty +// catalog string into "omit the field" on all three wires; this pins that +// so no future invalidate-without-rebuild can reach the wire malformed. +test "#695: an empty catalog string is omitted from the body, never serialized as tools:, (#695)" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + + var responses = try testAgentFor(a, "codex", .responses, "gpt-5.6-sol"); + const rb = try responses.buildBody("", false, true, true); + defer std.testing.allocator.free(rb); + try std.testing.expect(std.mem.indexOf(u8, rb, "\"tools\":,") == null); + try std.testing.expect(std.mem.indexOf(u8, rb, "\"tool_choice\"") == null); + try std.testing.expect(std.mem.indexOf(u8, rb, "\"parallel_tool_calls\"") == null); + + var chat = try testAgentFor(a, "xai", .openai, "grok-4.6"); + const cb = try chat.buildBody("", false, true, true); + defer std.testing.allocator.free(cb); + try std.testing.expect(std.mem.indexOf(u8, cb, "\"tools\":,") == null); + try std.testing.expect(std.mem.indexOf(u8, cb, "\"tool_choice\"") == null); + + var anthropic = try testAgentFor(a, "anthropic", .anthropic, "claude-sonnet-5"); + const ab = try anthropic.buildBody("", false, true, true); + defer std.testing.allocator.free(ab); + try std.testing.expect(std.mem.indexOf(u8, ab, "\"tools\":,") == null); + try std.testing.expect(std.mem.indexOf(u8, ab, "\"tool_choice\"") == null); + + // A real catalog still rides every wire untouched. + var live = try testAgentFor(a, "codex", .responses, "gpt-5.6-sol"); + const catalog = "[{\"type\":\"function\",\"name\":\"bash\",\"description\":\"\"}]"; + const lb = try live.buildBody(catalog, false, true, true); + defer std.testing.allocator.free(lb); + try std.testing.expect(std.mem.indexOf(u8, lb, "\"name\":\"bash\"") != null); +} + test "GPT-5.6 Platform marks the stable prefix; Codex and older routes do not" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); diff --git a/src/agent_request_scratch.zig b/src/agent_request_scratch.zig new file mode 100644 index 00000000..0f0ceb98 --- /dev/null +++ b/src/agent_request_scratch.zig @@ -0,0 +1,59 @@ +//! Per-request scratch and keep-alive retry helpers for the request loop. +//! +//! Split from agent_request.zig so that file stays under the 600-line ceiling +//! after the Codex WS error-frame merge (#693). + +const std = @import("std"); + +const Agent = @import("agent.zig").Agent; +const run_budget_mod = @import("run_budget.zig"); + +/// Keep the normal request hot path allocation-free while avoiding a permanent +/// RSS high-water mark after one anomalously large stream. Small scratch arenas +/// retain their pages for the next request; large ones return all pages to the +/// backing allocator. History never lives here, so either reset mode is safe at +/// the start of the next request. +const scratch_retain_limit = 4 * 1024 * 1024; + +pub fn reset(scratch: *std.heap.ArenaAllocator) void { + if (scratch.queryCapacity() > scratch_retain_limit) { + _ = scratch.reset(.free_all); + } else { + _ = scratch.reset(.retain_capacity); + } +} + +/// Detached recaps are cosmetic: keep recovered transport details in the trace, +/// but do not surface them as raw worker chatter in the normal REPL. +pub fn showRecoveredTransportRetry(kind: run_budget_mod.CallKind) bool { + return kind != .recap; +} + +/// A response body of only SSE comment lines (`: OPENROUTER PROCESSING` …) +/// means the gateway queued us and never produced tokens — back off and re-ask +/// like a 5xx instead of dying on an "unparseable" JSON parse. +pub fn retryKeepAliveOnly(self: *Agent, body: []const u8, retries: *usize) !bool { + return @import("agent_gateway_retry.zig").retryDegenerateBody(self, body, retries); +} + +test "recovered recap transport retries stay out of normal REPL output" { + try std.testing.expect(!showRecoveredTransportRetry(.recap)); + try std.testing.expect(showRecoveredTransportRetry(.root)); + try std.testing.expect(showRecoveredTransportRetry(.child)); +} + +test "request scratch retains normal capacity but releases an oversized spike" { + var scratch = std.heap.ArenaAllocator.init(std.testing.allocator); + defer scratch.deinit(); + + _ = try scratch.allocator().alloc(u8, 1024); + const normal_capacity = scratch.queryCapacity(); + try std.testing.expect(normal_capacity > 0); + reset(&scratch); + try std.testing.expectEqual(normal_capacity, scratch.queryCapacity()); + + _ = try scratch.allocator().alloc(u8, scratch_retain_limit + 1); + try std.testing.expect(scratch.queryCapacity() > scratch_retain_limit); + reset(&scratch); + try std.testing.expectEqual(@as(usize, 0), scratch.queryCapacity()); +} diff --git a/src/agent_responses.zig b/src/agent_responses.zig index 7ee0e0b8..90df207a 100644 --- a/src/agent_responses.zig +++ b/src/agent_responses.zig @@ -100,6 +100,48 @@ pub fn parseResponses(self: *Agent, body: []const u8) !ResponsesResult { return error.Unparseable; } +/// Safe diagnostic for a parsed Responses failure. This is the redaction +/// boundary for WS error frames: only the provider, structured code and message +/// survive — never the raw envelope, echoed request, headers or auth fields. +/// Each field is single-lined and bounded before it reaches last_api_error or +/// the default-on trace. +pub fn failureDiagnostic(allocator: std.mem.Allocator, provider: []const u8, failure: ResponsesFailure) ![]u8 { + var buf: [560]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try writeDiagnosticField(&w, provider, 40); + try w.writeAll(" api error"); + if (failure.code) |code| { + try w.writeAll(" ["); + try writeDiagnosticField(&w, code, 96); + try w.writeByte(']'); + } + try w.writeAll(": "); + try writeDiagnosticField(&w, failure.message, 384); + return allocator.dupe(u8, w.buffered()); +} + +fn writeDiagnosticField(w: *std.Io.Writer, raw: []const u8, max: usize) !void { + const prefix = util.utf8Prefix(raw, max); + for (prefix) |b| try w.writeByte(if (b < 0x20 or b == 0x7f) ' ' else b); + if (prefix.len < raw.len) try w.writeAll("…"); +} + +test "failureDiagnostic retains only bounded single-line code and message" { + const a = std.testing.allocator; + var long: [513]u8 = @splat('x'); + @memcpy(long[0..13], "bad request\r\n"); + const diagnostic = try failureDiagnostic(a, "codex", .{ + .code = "invalid_request_error\nignored-envelope", + .message = &long, + }); + defer a.free(diagnostic); + try std.testing.expect(std.mem.startsWith(u8, diagnostic, "codex api error [invalid_request_error ignored-envelope]: bad request ")); + try std.testing.expect(std.mem.endsWith(u8, diagnostic, "…")); + try std.testing.expect(diagnostic.len < 560); + try std.testing.expect(std.mem.indexOfScalar(u8, diagnostic, '\n') == null); + try std.testing.expect(std.mem.indexOfScalar(u8, diagnostic, '\r') == null); +} + test "parseResponses: terminal failure beats partial items; incomplete stays marked" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); diff --git a/src/agent_stream.zig b/src/agent_stream.zig index a4b6fe50..2cdafecc 100644 --- a/src/agent_stream.zig +++ b/src/agent_stream.zig @@ -24,6 +24,7 @@ const reasoningDelta = @import("title.zig").reasoningDelta; const stream_tests = @import("agent_stream_tests.zig"); const http = @import("http.zig"); +const http_client = @import("http_client.zig"); const http_headers = @import("http_headers.zig"); const providerUserAgent = http.providerUserAgent; const capture5xxBodyStream = http.capture5xxBodyStream; @@ -47,11 +48,17 @@ pub fn postStream(self: *Agent, body: []const u8) ![]u8 { return postStreamWithClient(self, self.client, body); } -/// SSE stream using an explicit HTTP client. Normal traffic uses the Agent's -/// shared pool; a WebSocket failure supplies a fresh client so a stale pooled -/// keep-alive cannot poison the WS→SSE handoff and every fallback retry dials -/// from a clean pool. +/// SSE stream using an explicit HTTP client. Normal traffic and the WS→SSE +/// latch both use the Agent's prewarmed pool (agent_ws.postLive); a test can +/// pass another client. A failed SEND still poisons that connection so the +/// next retry dials fresh instead of replaying a dead keep-alive. pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []const u8) ![]u8 { + http_client.waitForReady(client.io); + var lease = http_client.acquire(client); + defer lease.release(); + if (!lease.available) return error.Canceled; + if (http_client.injectedConstructionTls(&lease)) |err| return err; + const transport = lease.client; const sink = engine_sink.forAgent(self); sink.emit(self.io, .stream_begin); // Every exit path — success, interrupt, transport error — tears down the @@ -109,14 +116,17 @@ pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []cons _ = drainSteerStdin(true); restoreStdin(o); }; - var req = try client.request(.POST, try std.Uri.parse(provider.url), .{ + var req = transport.request(.POST, try std.Uri.parse(provider.url), .{ .redirect_behavior = .unhandled, .headers = .{ .content_type = .{ .override = "application/json" }, .user_agent = providerUserAgent(provider), }, .extra_headers = extra, - }); + }) catch |err| { + if (http_client.constructionTlsFailure(err, provider.url)) return http_client.constructionTlsError(transport); + return err; + }; defer req.deinit(); // A failed SEND leaves reader.state == .ready, which Request.deinit // reads as "connection still clean" and returns it to the keep-alive diff --git a/src/agent_tests.zig b/src/agent_tests.zig index 4f961763..51dc0bec 100644 --- a/src/agent_tests.zig +++ b/src/agent_tests.zig @@ -101,3 +101,7 @@ pub fn oneshotUsesLiveTransport(comptime Agent: type) !void { child.stream_quiet = true; try std.testing.expect(child.usesLiveTransport()); } + +test "say: an over-long worker line still ends its row (#tui-tick)" { + try workerLineAlwaysEndsRow(@import("agent.zig").Agent); +} diff --git a/src/agent_ws.zig b/src/agent_ws.zig index 69b9a38a..f6c6cd16 100644 --- a/src/agent_ws.zig +++ b/src/agent_ws.zig @@ -579,14 +579,12 @@ pub fn postResponsesWs(self: *Agent, body: []const u8) ![]u8 { try full.writer.writeAll(fbuf.items); try full.writer.writeByte('\n'); const line = try std.fmt.allocPrint(arena, "data: {s}", .{fbuf.items}); - // xAI error frames are terminal but not in isStreamEnd's set. Both - // arms route through postLive's close + redial (resets chain state). - switch (signal.errorFrameAction(fbuf.items)) { - .none => {}, - .retire, .chain_lost => |act| { - if (self.tracer) |tr| tr.note("ws", if (act == .retire) "server connection limit — retiring socket" else "chain anchor gone — re-anchoring full"); - return error.ConnectionResetByPeer; - }, + // Codex closes after `type:error`; return the accumulated API body + // instead of waiting for EOF and misclassifying it as transport loss. + if (signal.errorFrameAction(gpa, fbuf.items) != .none) { + client.dead = true; // no courtesy close write to a closing peer + if (self.tracer) |tr| tr.note("ws", "terminal API error frame"); + break :stream; } if (isStreamEnd(arena, self.provider.kind, line)) { if (self.tracer) |tr| tr.note("ws", "completed"); diff --git a/src/agent_ws_mock.zig b/src/agent_ws_mock.zig index 1f81e65c..70871df0 100644 --- a/src/agent_ws_mock.zig +++ b/src/agent_ws_mock.zig @@ -20,6 +20,7 @@ pub fn nowMs(io: Io) i64 { pub const delta_event = "{\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}"; pub const completed_event = "{\"type\":\"response.completed\"}"; +pub const generic_error_event = "{\"type\":\"error\",\"error\":{\"code\":\"invalid_request_error\",\"message\":\"mock bad request\"}}"; /// What the backend actually puts on the socket in the first milliseconds after /// a send, before the model has produced anything: the two protocol events, then @@ -76,6 +77,9 @@ pub const Mock = struct { /// Upgrade, take the client's frame, stream a whitelisted meta call's /// arguments (`arg_prose_events`) and then go silent. arg_prose_then_silence, + /// Send a generic terminal API error and close immediately, matching + /// Codex's failure sequence from issue #692. + generic_error_then_close, }; pub fn run(io: Io, server: *std.Io.net.Server, mode: Mode, done: *std.atomic.Value(bool)) void { @@ -119,6 +123,11 @@ pub const Mock = struct { for (arg_prose_events) |ev| writeTextFrame(&sw.interface, ev) catch return idle(io, done); }, + .generic_error_then_close => { + readClientFrame(&sr.interface) catch return; + writeTextFrame(&sw.interface, generic_error_event) catch return; + return; + }, } idle(io, done); } diff --git a/src/agent_ws_reuse_test.zig b/src/agent_ws_reuse_test.zig index cb6dbc64..0f7a84b3 100644 --- a/src/agent_ws_reuse_test.zig +++ b/src/agent_ws_reuse_test.zig @@ -233,3 +233,42 @@ test "#401: a fresh connect keeps the full pre-first-token budget for a slow fir try std.testing.expect(!traced(&tw, "\"detail\":\"stall\"")); try std.testing.expect(agent.codex_ws != null); // held for the next delta } + +test "#692: a generic error frame is returned as the terminal API body before peer close" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + + var addr = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&addr, io, .{}); + defer server.deinit(io); + var done: std.atomic.Value(bool) = .init(false); + var fut = io.async(Mock.run, .{ io, &server, Mock.Mode.generic_error_then_close, &done }); + defer fut.await(io); + defer done.store(true, .release); + + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var tw: Io.Writer.Allocating = .init(gpa); + defer tw.deinit(); + var tracer: trace.Tracer = .{ .io = io, .gpa = gpa, .out = &tw.writer, .start = Io.Timestamp.now(io, .awake) }; + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/x", .{server.socket.address.getPort()}); + var agent = mockAgent(gpa, arena, io, url); + agent.tracer = &tracer; + defer if (agent.codex_ws) |c| { + c.dead = true; + c.deinit(gpa); + agent.codex_ws = null; + }; + + const out = try agent_ws.postResponsesWs(&agent, "{\"model\":\"gpt-5\",\"input\":[]}"); + defer gpa.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, mock.generic_error_event) != null); + try std.testing.expect(traced(&tw, "\"detail\":\"terminal API error frame\"")); + try std.testing.expect(!traced(&tw, "transport error")); + try std.testing.expect(agent.codex_ws != null); + try std.testing.expect(agent.codex_ws.?.dead); +} diff --git a/src/agent_ws_signal.zig b/src/agent_ws_signal.zig index 8fc7f529..b503c482 100644 --- a/src/agent_ws_signal.zig +++ b/src/agent_ws_signal.zig @@ -127,18 +127,31 @@ pub const TokenSignal = struct { } }; -/// xAI WS error frames ({"type":"error"}) are terminal for the turn but not in -/// isStreamEnd's completed/failed set, so without classification they burn the -/// whole stall budget on a doomed socket. -pub const ErrorFrameAction = enum { none, retire, chain_lost }; +/// Responses WS `type:error` frames are terminal API responses, not transport +/// failures. The two known chain errors retain distinct labels for trace +/// diagnostics, but all three actions return the accumulated response body to +/// parseResponses instead of waiting for the peer's expected close frame. +pub const ErrorFrameAction = enum { none, api_error, retire, chain_lost }; -pub fn errorFrameAction(frame: []const u8) ErrorFrameAction { - if (std.mem.indexOf(u8, frame, "\"type\":\"error\"") == null) return .none; - // The server sends this right before closing a 25-minute-old socket. - if (std.mem.indexOf(u8, frame, "websocket_connection_limit_reached") != null) return .retire; - // Our chain anchor is gone (evicted / never cached under store:false). - if (std.mem.indexOf(u8, frame, "previous_response_not_found") != null) return .chain_lost; - return .none; +pub fn errorFrameAction(gpa: std.mem.Allocator, frame: []const u8) ErrorFrameAction { + // Error frames are rare. Parse only a cheap candidate so whitespace around + // `type` is accepted and prose that merely quotes `"type":"error"` is not. + if (std.mem.indexOf(u8, frame, "error") == null) return .none; + var scratch = std.heap.ArenaAllocator.init(gpa); + defer scratch.deinit(); + const v = std.json.parseFromSliceLeaky(std.json.Value, scratch.allocator(), frame, .{ .allocate = .alloc_always }) catch return .none; + if (v != .object) return .none; + const ty = v.object.get("type") orelse return .none; + if (ty != .string or !std.mem.eql(u8, ty.string, "error")) return .none; + const err = v.object.get("error"); + const code = if (err) |e| (if (e == .object) e.object.get("code") else null) else null; + if (code) |c| if (c == .string) { + // The server sends this right before closing a 25-minute-old socket. + if (std.mem.eql(u8, c.string, "websocket_connection_limit_reached")) return .retire; + // Our chain anchor is gone (evicted / never cached under store:false). + if (std.mem.eql(u8, c.string, "previous_response_not_found")) return .chain_lost; + }; + return .api_error; } /// The deadline for writing a frame of `frame_len` bytes. @@ -156,9 +169,11 @@ pub fn sendDeadlineMs(frame_len: usize, head_ms: u64, stream_ms: u64) u64 { return @min(head_ms +| grow, @max(head_ms, stream_ms)); } -test "errorFrameAction classifies the two xAI ws error codes, ignores prose" { - try std.testing.expectEqual(ErrorFrameAction.retire, errorFrameAction("{\"type\":\"error\",\"error\":{\"code\":\"websocket_connection_limit_reached\"}}")); - try std.testing.expectEqual(ErrorFrameAction.chain_lost, errorFrameAction("{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\"}}")); - try std.testing.expectEqual(ErrorFrameAction.none, errorFrameAction("{\"type\":\"response.output_text.delta\",\"delta\":\"websocket_connection_limit_reached\"}")); - try std.testing.expectEqual(ErrorFrameAction.none, errorFrameAction("{\"type\":\"error\",\"error\":{\"code\":\"other\"}}")); +test "errorFrameAction classifies every actual error frame and ignores quoted prose" { + const a = std.testing.allocator; + try std.testing.expectEqual(ErrorFrameAction.retire, errorFrameAction(a, "{\"type\":\"error\",\"error\":{\"code\":\"websocket_connection_limit_reached\"}}")); + try std.testing.expectEqual(ErrorFrameAction.chain_lost, errorFrameAction(a, "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\"}}")); + try std.testing.expectEqual(ErrorFrameAction.api_error, errorFrameAction(a, "{ \"type\" : \"error\", \"error\" : {\"code\":\"invalid_request_error\"} }")); + try std.testing.expectEqual(ErrorFrameAction.none, errorFrameAction(a, "{\"type\":\"response.output_text.delta\",\"delta\":\"quoted \\\"type\\\":\\\"error\\\"\"}")); + try std.testing.expectEqual(ErrorFrameAction.none, errorFrameAction(a, "not json: error")); } diff --git a/src/agent_ws_test.zig b/src/agent_ws_test.zig index fccbfe19..34eaf263 100644 --- a/src/agent_ws_test.zig +++ b/src/agent_ws_test.zig @@ -79,7 +79,7 @@ test "codexWsIdleExpired: fires only strictly past the idle limit (codex-ws)" { test "the codex .responses arm refreshes auth and re-anchors before resending (#402)" { const src = @embedFile("agent_request.zig"); const arm_start = std.mem.indexOf(u8, src, "unparseable codex response").?; - const arm_end = std.mem.indexOf(u8, src, "{s} api error: {s}").?; + const arm_end = std.mem.indexOf(u8, src, "try self.sayApiError(\"{s}\", .{diagnostic})").?; const arm = src[arm_start..arm_end]; const call = std.mem.indexOf(u8, arm, "retryAfterAuthRefresh(self, msg, &auth_refreshed)") orelse diff --git a/src/args.zig b/src/args.zig index ffd5c496..3f28ca1d 100644 --- a/src/args.zig +++ b/src/args.zig @@ -60,7 +60,8 @@ pub const Flags = struct { host_flag: []const u8 = "127.0.0.1", // harness serve port_flag: u16 = 8787, // harness serve token_flag: ?[]const u8 = null, // harness serve - resume_flag: ?[]const u8 = null, // restore/save this named session + resume_flag: ?[]const u8 = null, // restore this named session + branch_flag: ?[]const u8 = null, // clone --resume into this independent autosave target goal_flag: ?[]const u8 = null, // --goal: standing objective (todos) every turn gets, incl. --json/-p eval_cmd_flag: ?[]const u8 = null, // --eval: scoring command for the eval-driven loop worktree_flag: ?[]const u8 = null, // --worktree/-w: isolate this session in a git worktree (parallel agents, no file collisions) @@ -206,6 +207,9 @@ pub fn parse(init: std.process.Init) !Flags { } else if (std.mem.eql(u8, arg, "--resume")) { const rv = it.next() orelse std.process.fatal("--resume needs a session name — harness --help", .{}); flags.resume_flag = try arena.dupe(u8, rv); + } else if (std.mem.eql(u8, arg, "--branch")) { + const bv = it.next() orelse std.process.fatal("--branch needs a destination session name — harness --help", .{}); + flags.branch_flag = try arena.dupe(u8, bv); } else if (std.mem.eql(u8, arg, "--no-resume")) { flags.no_resume_flag = true; } else if (std.mem.eql(u8, arg, "--new")) { @@ -261,6 +265,8 @@ pub fn parse(init: std.process.Init) !Flags { flags.oneshot_prompt = try std.mem.join(arena, " ", flags.positionals.items); } if (flags.print_flag and flags.oneshot_prompt == null) std.process.fatal("-p needs a prompt: harness -p \"do something\"", .{}); + if (flags.branch_flag != null and flags.resume_flag == null) std.process.fatal("--branch needs --resume ", .{}); + if (flags.branch_flag != null and (flags.new_session_flag or flags.no_resume_flag)) std.process.fatal("--branch cannot be combined with --new or --no-resume", .{}); // The tool-surface half of lean, set AFTER the one-shot prompt is // assembled: on for --lean and for every one-shot without --no-lean (the diff --git a/src/cli.zig b/src/cli.zig index 8288118a..0dcb2da9 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -21,6 +21,15 @@ const harness_version = root.harness_version; pub const changelog_text = \\What's new \\────────── + \\0.0.282 + \\ • pasted-text chips are atomic spans — a typed lookalike stays literal (#674) + \\ • Codex WS type:error is a terminal API response; last_api_error stays (#693) + \\ • request-construction TLS failure rotates a leased HTTP client generation (ADR 0048) + \\ • --resume SOURCE --branch DEST is clone-on-write (ADR 0049) + \\ • MCP HTTP/WSS reuse warmed TLS; catalogs can arrive gzip (ADR 0050) + \\ • /teleport + /snapshot gc (ADR 0051); DeepSeek thinking off at low (ADR 0054) + \\ • lean -p bounces prose-only done (ADR 0052) and backgrounds bash at 15s (ADR 0055) + \\ \\0.0.281 \\ • -p / --json skip learn auto-init — no 132M graff-pinned copy (ADR 0044) \\ • SuperGrok SWE 5/6 in 205s / 6.5s CPU (was 4/6 in 456s / 230s) @@ -274,6 +283,7 @@ pub const usage_text = \\ --allow-cross-provider-subagents confirm prompts/code may go to the worker provider \\ --no-subagent-tier opt out of the default worker tier ladder (inherit the root model) \\ --resume resume/autosave .session.json + \\ --branch clone --resume into an independent autosave target \\ --new start a fresh autosaved session (default) \\ --no-resume ignore --resume and start fresh \\ --system-prompt replace the built-in system prompt diff --git a/src/command_catalog.zig b/src/command_catalog.zig index fcdac633..41fb9236 100644 --- a/src/command_catalog.zig +++ b/src/command_catalog.zig @@ -59,13 +59,14 @@ pub const commands = [_]Item{ .{ .name = "/btw", .usage = "/btw ", .desc = "ask one side question about this conversation — billed, never added, rides the parent cache prefix" }, .{ .name = "/compact", .desc = "compact history into a fresh context (OpenAI server-side when available)" }, .{ .name = "/rewind", .usage = "/rewind [n|]", .desc = "list past prompts; /rewind drops prompt n+after & reverts its file edits; /rewind restores a sandbox filesystem instead, never the conversation" }, - .{ .name = "/snapshot", .usage = "/snapshot [attach [image]|detach|list]", .desc = "capture the attached sandbox's filesystem under .graff/sessions//snapshots; attach puts this session in a Docker sandbox, list shows what it has captured" }, + .{ .name = "/snapshot", .usage = "/snapshot [attach [image]|detach|list|gc [n]]", .desc = "capture the attached sandbox's filesystem under .graff/sessions//snapshots; attach puts this session in a Docker sandbox, list shows what it has captured, gc keeps the newest n (default 1)" }, + .{ .name = "/teleport", .usage = "/teleport [docker|container]", .desc = "restore a docker_image_tar snapshot onto a different CLI backend (Apple Container by default); filesystem only, never the conversation" }, .{ .name = "/image", .usage = "/image ", .desc = "attach an image to your next message (vision models only)" }, .{ .name = "/images", .desc = "open image URLs from the last response (e.g. issue attachments) in your browser" }, .{ .name = "/paste", .desc = "attach the clipboard image — macOS; also Ctrl-V (⌘V can't be captured)" }, .{ .name = "/bash", .usage = "/bash ", .desc = "run a shell command directly" }, .{ .name = "/save", .usage = "/save [name]", .desc = "write the conversation to .session.json (default: current)" }, - .{ .name = "/resume", .usage = "/resume [name]", .desc = "restore a saved conversation (no arg → interactive picker)" }, + .{ .name = "/resume", .usage = "/resume [source] [--branch destination]", .desc = "restore a saved conversation, optionally cloning it into an independent branch" }, .{ .name = "/sessions", .desc = "list saved sessions in the cwd" }, .{ .name = "/workspace", .usage = "/workspace [list|use ]", .desc = "list git worktrees or switch this session into one (file tools follow)" }, .{ .name = "/experiment", .usage = "/experiment [N|off|status]", .desc = "pre-mint N child worktrees (1-16) and seat the next spawns in them; off clears the pool" }, diff --git a/src/commands_misc.zig b/src/commands_misc.zig index aad656ec..289a346a 100644 --- a/src/commands_misc.zig +++ b/src/commands_misc.zig @@ -20,7 +20,6 @@ const mcp_config_path = main_mod.mcp_config_path; const session_ext = session.session_ext; const saveSession = session.saveSession; const session = @import("session.zig"); -const loadSession = session.loadSession; const listSavedSessions = session.listSavedSessions; const sessionAge = session.sessionAge; @@ -498,62 +497,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, try out.flush(); return true; } - if (std.mem.startsWith(u8, line, "/resume")) { - root.ensureStoredKeys(keys); - const arg = std.mem.trim(u8, line["/resume".len..], " \t"); - var name: []const u8 = ownedSessionName(arena, arg, "last") catch |err| { - try out.print("resume failed: {t}\n", .{err}); - try out.flush(); - return true; - }; - // Bare /resume on a TTY: pick from the saved sessions interactively, - // labeled by stored title + age instead of raw file names (#109). - if (arg.len == 0 and main_mod.use_color and root.in != null) { - var entries = listSavedSessions(root, arena); - defer entries.deinit(arena); - if (entries.items.len == 0) { - try out.writeAll("(no saved sessions in cwd — /save creates one)\n"); - try out.flush(); - return true; - } - var sessions: std.ArrayList(PickItem) = .empty; - defer sessions.deinit(arena); - for (entries.items) |e| { - const age = sessionAge(arena, root.io, e.updated_ms); - const desc = if (e.title == null) - age - else if (age.len > 0) - std.fmt.allocPrint(arena, "{s} · {s}", .{ age, e.base }) catch e.base - else - e.base; - try sessions.append(arena, .{ .name = e.title orelse e.base, .desc = desc }); - } - const idx = listPicker(root, arena, out, "Resume session ›", sessions.items) orelse return true; - name = entries.items[idx].base; // arena-owned by listSavedSessions, so it outlives the turn too - } - loadSession(root, keys, arena, name) catch |err| { - switch (err) { - error.FileNotFound => try out.print("no session named '{s}' ({s}{s} not found in cwd) — /sessions lists saved ones\n", .{ name, name, session_ext }), - else => try out.print("resume failed: {t}\n", .{err}), - } - try out.flush(); - return true; - }; - root.session_name = name; - // #445: after the rename, so the re-arm reads the resumed session. The - // history just loaded IS that file's contents, so the #410 line would - // again only describe what the live window already holds. - prompts.resetSessionCompacted(root, arena); - // The third restore path (#318): --goal outranks the restored goal here - // too, idempotently, or /resume was the one door that silently dropped it. - if (root.goal_flag) |g| root.pending_goal_note = goal_flow.reapplyFlagGoal(arena, root, g, util.unixMs(root.io)) catch null; - try out.print("resumed {s}{s} — {d} message(s), {s} via {s}{s}\n", .{ - name, session_ext, root.messages.items.len, root.provider.model, root.provider.id, - if (root.strict) " (strict)" else "", - }); - try out.flush(); - return true; - } + if (try @import("commands_resume.zig").tryHandle(root, keys, arena, line, out)) return true; if (std.mem.startsWith(u8, line, "/tell") and (line.len == 5 or line[5] == ' ' or line[5] == '\t')) return peer_channel.tellCommand(root, arena, line, out); // #469 if (std.mem.startsWith(u8, line, "/peek") and (line.len == 5 or line[5] == ' ' or line[5] == '\t')) return peer_channel.peekCommand(root, arena, line, out); // #469 if (std.mem.startsWith(u8, line, "/routes") and (line.len == 7 or line[7] == ' ' or line[7] == '\t')) return route_set.command(root, keys, arena, line, out); // user-defined priced lanes @@ -563,10 +507,11 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, for (entries.items) |e| { const age = sessionAge(arena, root.io, e.updated_ms); const cur = if (std.mem.eql(u8, e.base, root.session_name)) " ← current" else ""; + const parent = if (e.parent) |p| std.fmt.allocPrint(arena, " ← {s}", .{p}) catch "" else ""; if (e.title) |t| { - try out.print(" {s} {s}{s}{s}{s}{s}{s}\n", .{ t, style.dim, e.base, if (age.len > 0) " · " else "", age, style.reset, cur }); + try out.print(" {s} {s}{s}{s}{s}{s}{s}{s}\n", .{ t, style.dim, e.base, parent, if (age.len > 0) " · " else "", age, style.reset, cur }); } else { - try out.print(" {s}{s}{s}{s}{s}{s}\n", .{ e.base, style.dim, if (age.len > 0) " " else "", age, style.reset, cur }); + try out.print(" {s}{s}{s}{s}{s}{s}{s}\n", .{ e.base, parent, style.dim, if (age.len > 0) " " else "", age, style.reset, cur }); } } if (entries.items.len == 0) try out.writeAll("(no saved sessions in cwd)\n"); diff --git a/src/commands_resume.zig b/src/commands_resume.zig new file mode 100644 index 00000000..8028e7a2 --- /dev/null +++ b/src/commands_resume.zig @@ -0,0 +1,81 @@ +//! `/resume` and clone-on-write `/resume SOURCE --branch DEST`. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const agent_mod = @import("agent.zig"); +const provider_mod = @import("provider.zig"); +const session = @import("session.zig"); +const session_branch = @import("session_branch.zig"); +const main_mod = @import("main.zig"); +const pickers = @import("pickers.zig"); + +const Agent = agent_mod.Agent; +const Keys = provider_mod.Keys; +const PickItem = pickers.PickItem; + +fn pickSource(root: *Agent, arena: Allocator, out: *Io.Writer) ?[]const u8 { + var entries = session.listSavedSessions(root, arena); + defer entries.deinit(arena); + if (entries.items.len == 0) { + out.writeAll("(no saved sessions in cwd — /save creates one)\n") catch {}; + out.flush() catch {}; + return null; + } + var choices: std.ArrayList(PickItem) = .empty; + defer choices.deinit(arena); + for (entries.items) |e| { + const age = session.sessionAge(arena, root.io, e.updated_ms); + const desc = if (e.title == null) + age + else if (age.len > 0) + std.fmt.allocPrint(arena, "{s} · {s}", .{ age, e.base }) catch e.base + else + e.base; + choices.append(arena, .{ .name = e.title orelse e.base, .desc = desc }) catch return null; + } + const idx = pickers.listPicker(root, arena, out, "Resume session ›", choices.items) orelse return null; + return entries.items[idx].base; +} + +fn reject(out: *Io.Writer, comptime fmt: []const u8, args: anytype) !bool { + try out.print(fmt, args); + try out.flush(); + return true; +} + +pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, out: *Io.Writer) !bool { + if (!std.mem.startsWith(u8, line, "/resume") or (line.len > 7 and line[7] != ' ' and line[7] != '\t')) return false; + const parsed = session_branch.parseSpec(line["/resume".len..]) orelse return reject(out, "usage: /resume SOURCE [--branch DEST]\n", .{}); + var source = parsed.source; + if (source.len == 0) { + if (!(main_mod.use_color and root.in != null)) return reject(out, "usage: /resume SOURCE [--branch DEST]\n", .{}); + source = pickSource(root, arena, out) orelse return true; + } + + const resumed = session_branch.restore(root, keys, arena, source, parsed.branch) catch |err| return switch (err) { + error.FileNotFound => reject(out, "no session named '{s}' ({s}{s} not found in cwd) — /sessions lists saved ones\n", .{ source, source, session.session_ext }), + error.InvalidSessionName => reject(out, "resume failed: invalid source or branch name\n", .{}), + error.BranchMatchesSource => reject(out, "branch failed: destination must differ from source\n", .{}), + error.BranchAlreadyExists => reject(out, "branch failed: destination already exists\n", .{}), + else => reject(out, "resume failed: {t}\n", .{err}), + }; + if (resumed.branched) { + try out.print("branched {s}{s} → {s}{s} — {d} message(s), {s} via {s}{s}\n", .{ resumed.source, session.session_ext, resumed.target, session.session_ext, root.messages.items.len, root.provider.model, root.provider.id, if (root.strict) " (strict)" else "" }); + } else { + try out.print("resumed {s}{s} — {d} message(s), {s} via {s}{s}\n", .{ source, session.session_ext, root.messages.items.len, root.provider.model, root.provider.id, if (root.strict) " (strict)" else "" }); + } + try out.flush(); + return true; +} + +test "resume argument parser separates an explicit branch" { + const plain = session_branch.parseSpec(" baseline ").?; + try std.testing.expectEqualStrings("baseline", plain.source); + try std.testing.expect(plain.branch == null); + const forked = session_branch.parseSpec("baseline --branch branch-a").?; + try std.testing.expectEqualStrings("baseline", forked.source); + try std.testing.expectEqualStrings("branch-a", forked.branch.?); + try std.testing.expect(session_branch.parseSpec("baseline --branch ") == null); +} diff --git a/src/commands_sandbox.zig b/src/commands_sandbox.zig index 04f6a342..a386abcd 100644 --- a/src/commands_sandbox.zig +++ b/src/commands_sandbox.zig @@ -1,6 +1,7 @@ //! #554's REPL surface: `/snapshot` captures the active sandbox's filesystem //! into `.graff/sessions//snapshots//`, `/rewind ` brings one -//! back up. +//! back up, `/teleport ` restores the same tar on a different CLI backend, +//! and `/snapshot gc` drops old snapshot trees. //! //! `/rewind` was already taken by the CONVERSATION rewind (commands_model.zig, //! `/rewind `), and the two are deliberately the same word: they are the @@ -32,10 +33,11 @@ const sandbox = @import("sandbox.zig"); const sandbox_docker = @import("sandbox_docker.zig"); const util = @import("util.zig"); -/// The process's Docker backend, once `/snapshot attach` has built one. A -/// live `Handle` points into a Sandbox that points back here, so this outlives -/// any one command — the same process-wide shape as sandbox.active(). +/// Process-wide CLI backends. A live `Handle` points into a Sandbox that +/// points back here, so these outlive any one command — the same shape as +/// sandbox.active(). `g_container` is the Apple Container slot teleport uses. var g_docker: sandbox_docker.Docker = undefined; +var g_container: sandbox_docker.Docker = undefined; /// The sentence every rewind output carries. A user who believed the /// transcript had moved too would read it as the record of a run that never @@ -56,15 +58,16 @@ pub fn explain(err: anyerror) []const u8 { error.SandboxUnavailable => "the sandbox backend is not usable here — `docker` is not on PATH, or the daemon refused the command", error.SnapshotUnsupported => "this backend cannot capture state (the local backend runs without isolation, so there is nothing to snapshot) — attach a Docker sandbox instead", error.SnapshotFailed => "the capture failed — `docker commit`/`docker save` did not succeed", - error.RestoreFailed => "the restore failed — `docker load`/`docker run` did not succeed, and the previous sandbox was already released; nothing is attached now, so `/snapshot attach` starts a fresh one", + error.RestoreFailed => "the restore failed — `load`/`run` did not succeed, and the previous sandbox was already released; nothing is attached now, so `/snapshot attach` starts a fresh one", error.ExecFailed => "the sandbox command could not be run", else => "the sandbox operation failed", }; } /// `/snapshot` (capture), `/snapshot attach [image]`, `/snapshot detach`, -/// `/snapshot list`, and `/rewind `. Returns false for anything else, -/// including `/rewind` with a numeric or absent argument. +/// `/snapshot list`, `/snapshot gc [n]`, `/teleport [backend]`, and +/// `/rewind `. Returns false for anything else, including `/rewind` with +/// a numeric or absent argument. pub fn tryHandle(root: *Agent, arena: Allocator, line: []const u8, out: *Io.Writer) !bool { if (argOf(line, "/snapshot")) |arg| { if (std.mem.eql(u8, arg, "list") or std.mem.eql(u8, arg, "ls")) { @@ -73,12 +76,19 @@ pub fn tryHandle(root: *Agent, arena: Allocator, line: []const u8, out: *Io.Writ try attach(root, image, out); } else if (std.mem.eql(u8, arg, "detach")) { try detach(out); + } else if (argOf(arg, "gc") != null or std.mem.eql(u8, arg, "gc")) { + try collect(root, arena, if (argOf(arg, "gc")) |n| n else "", out); } else { try capture(root, arena, out); } try out.flush(); return true; } + if (argOf(line, "/teleport")) |arg| { + try teleport(root, arena, arg, out); + try out.flush(); + return true; + } if (argOf(line, "/rewind")) |arg| { if (!isSnapshotArg(arg)) return false; // the conversation rewind owns it try restore(root, arena, arg, out); @@ -88,6 +98,14 @@ pub fn tryHandle(root: *Agent, arena: Allocator, line: []const u8, out: *Io.Writ return false; } +/// Named CLI backend. `docker` is the default attach; `container` is Apple +/// Container — same wire, different binary. Unknown names stay null so the +/// command can explain instead of guessing. +pub fn backendNamed(name: []const u8) ?[]const u8 { + if (std.mem.eql(u8, name, "docker") or std.mem.eql(u8, name, "container")) return name; + return null; +} + /// The argument after `cmd`, or null when `line` is a different command. /// `/rewinds` is not `/rewind`: the next byte has to be a space or the end. fn argOf(line: []const u8, cmd: []const u8) ?[]const u8 { @@ -109,11 +127,7 @@ fn attach(root: *Agent, image: []const u8, out: *Io.Writer) !void { try out.print("a sandbox is already attached ({s}) — /snapshot detach first\n", .{prev.handle.id}); return; } - g_docker = .{ - .io = root.io, - .path_env = main_mod.g_path_env, - .image = if (image.len > 0) image else sandbox_docker.default_image, - }; + g_docker = fillCli(root, "docker", image); const backend = g_docker.backend(); if (!backend.available()) { try out.print("docker backend: {s}\n", .{explain(error.SandboxUnavailable)}); @@ -211,6 +225,97 @@ fn restore(root: *Agent, arena: Allocator, id: []const u8, out: *Io.Writer) !voi try out.print("{s} {s}; processes inside the sandbox were relaunched, not resumed{s}\n", .{ style.dim, log_note, style.reset }); } +fn fillCli(root: *Agent, bin_name: []const u8, image: []const u8) sandbox_docker.Docker { + return .{ + .io = root.io, + .path_env = main_mod.g_path_env, + .image = if (image.len > 0) image else sandbox_docker.default_image, + .bin_name = bin_name, + }; +} + +fn slot(bin_name: []const u8) *sandbox_docker.Docker { + return if (std.mem.eql(u8, bin_name, "container")) &g_container else &g_docker; +} + +/// `/snapshot gc [n]` — keep the newest n snapshots (default 1). The exo miss +/// #554 named: leftover tars pile up unless something deletes them. +fn collect(root: *Agent, arena: Allocator, keep_arg: []const u8, out: *Io.Writer) !void { + if (!sandbox.safeName(root.session_name)) { + try out.writeAll("this session has no name a snapshot directory could be built from\n"); + return; + } + const keep: usize = if (keep_arg.len == 0) 1 else std.fmt.parseInt(usize, keep_arg, 10) catch { + try out.print("usage: /snapshot gc [n] — n is how many newest snapshots to keep (default 1)\n", .{}); + return; + }; + const result = try sandbox.gc(root.io, sandbox.workspace(), arena, root.session_name, keep); + if (result.removed == 0) { + try out.print("snapshot gc: nothing to drop — {d} kept\n", .{result.kept}); + return; + } + try out.print("snapshot gc: dropped {d} ({d} bytes), kept {d}\n", .{ result.removed, result.bytes, result.kept }); +} + +/// Restore `id` onto a backend other than the one that captured it. Dest +/// defaults to `container` when the live backend is docker (or nothing is +/// attached), and `docker` when the live backend is already container. +fn teleport(root: *Agent, arena: Allocator, arg: []const u8, out: *Io.Writer) !void { + var it = std.mem.tokenizeAny(u8, arg, " \t"); + const id = it.next() orelse { + try out.writeAll("usage: /teleport [docker|container]\n"); + return; + }; + const dest_arg = it.next() orelse ""; + const dest = if (dest_arg.len == 0) + defaultDest() + else + backendNamed(dest_arg) orelse { + try out.print("unknown backend {s} — teleport dest is docker or container\n", .{dest_arg}); + return; + }; + try restoreOnto(root, arena, id, dest, out); +} + +fn defaultDest() []const u8 { + const act = sandbox.active() orelse return "container"; + return if (std.mem.eql(u8, act.backend.name(), "container")) "docker" else "container"; +} + +fn restoreOnto(root: *Agent, arena: Allocator, id: []const u8, dest_name: []const u8, out: *Io.Writer) !void { + const found = sandbox.find(root.io, sandbox.workspace(), arena, root.session_name, id) orelse { + try out.print("no snapshot {s} in this session — {s}/snapshot list{s} shows what there is\n", .{ id, style.accent, style.reset }); + return; + }; + const dest_slot = slot(dest_name); + dest_slot.* = fillCli(root, dest_name, ""); + const dest = dest_slot.backend(); + if (!dest.available()) { + try out.print("{s} backend: {s}\n", .{ dest.name(), explain(error.SandboxUnavailable) }); + return; + } + const blob: sandbox.Blob = .{ + .io = root.io, + .dir = sandbox.workspace(), + .rel = try sandbox.payloadPath(arena, root.session_name, found.id), + }; + const key = if (sandbox.active()) |act| + try arena.dupe(u8, act.handle.key) + else + try arena.dupe(u8, root.session_name); + if (sandbox.active()) |act| { + act.handle.release(); + sandbox.detach(); + } + const fresh = dest.acquireFromSnapshot(root.gpa, key, found.payload(), blob) catch |err| { + try out.print("teleport failed — {s}\n", .{explain(err)}); + return; + }; + sandbox.attach(.{ .backend = dest, .handle = fresh }); + try out.print("teleported {s}{s}{s} onto {s} ({d} bytes)\n", .{ style.accent, found.id, style.reset, dest.name(), found.len }); + try out.print("{s} {s}; processes inside the sandbox were relaunched, not resumed{s}\n", .{ style.dim, log_note, style.reset }); +} + fn renderList(root: *Agent, arena: Allocator, out: *Io.Writer) !void { const items = try sandbox.list(root.io, sandbox.workspace(), arena, root.session_name); if (items.len == 0) { @@ -221,7 +326,7 @@ fn renderList(root: *Agent, arena: Allocator, out: *Io.Writer) !void { for (items) |m| { try out.print(" {s}{s}{s} {s:<8} {d:>10} B {s}\n", .{ style.accent, m.id, style.reset, m.backend, m.len, m.ref }); } - try out.print("{s}usage: /rewind — restores that filesystem; {s}{s}\n", .{ style.dim, log_note, style.reset }); + try out.print("{s}usage: /rewind restores here; /teleport [docker|container] restores onto another backend; /snapshot gc drops old tars; {s}{s}\n", .{ style.dim, log_note, style.reset }); } test "a numeric /rewind argument stays with the conversation rewind" { @@ -238,6 +343,14 @@ test "argOf claims only the exact command" { try std.testing.expect(argOf("/snapshots", "/snapshot") == null); try std.testing.expect(argOf("/rewinds 3", "/rewind") == null); try std.testing.expectEqualStrings("3", argOf("/rewind 3", "/rewind").?); + try std.testing.expectEqualStrings("id container", argOf("/teleport id container", "/teleport").?); +} + +test "teleport dest names are docker or container" { + try std.testing.expectEqualStrings("docker", backendNamed("docker").?); + try std.testing.expectEqualStrings("container", backendNamed("container").?); + try std.testing.expect(backendNamed("daytona") == null); + try std.testing.expect(backendNamed("") == null); } test "every sandbox failure has an explanation, and rewind never claims the log moved" { diff --git a/src/effort_route.zig b/src/effort_route.zig index 48bfa346..336a36b9 100644 --- a/src/effort_route.zig +++ b/src/effort_route.zig @@ -61,7 +61,9 @@ pub fn omitsDefaultFlashEffort(model: []const u8) bool { /// Wire `reasoning_effort` for an effort-capable provider. Flash / Gemini /// default `medium` becomes `low`: omit still thinks (27 reasoning_tokens -/// on a `pong`); `low` bills 0. `none` is a 400. `/effort high` unchanged. +/// on a `pong`); `low` bills 0 on GLM. DeepSeek V4 still thinks at `low` +/// until `thinking.type=disabled` (ADR 0054). `none` is a 400. `/effort high` +/// unchanged. pub fn wireEffort(model: []const u8, requested: []const u8) []const u8 { if (std.mem.eql(u8, requested, "medium") and omitsDefaultFlashEffort(model)) return "low"; if (std.mem.eql(u8, requested, "ultra")) return "max"; diff --git a/src/exec_bash.zig b/src/exec_bash.zig index 50486731..d2256c1c 100644 --- a/src/exec_bash.zig +++ b/src/exec_bash.zig @@ -27,6 +27,10 @@ const exec_bash_stream = @import("exec_bash_stream.zig"); /// the job registry instead of being killed (xai-org/grok-build BashTool). pub const root_wait_ms: u64 = 120 * 1000; +/// Lean `-p` has no human watching a hung test (ADR 0055). DeepSeek flash +/// SWE sat 120s on `python3 test_json_stream.py`. Interactive stays 120s. +pub const lean_oneshot_wait_ms: u64 = 15 * 1000; + /// Wall-clock ceiling for one *subagent* bash command. Subagents run on pool /// threads with no TTY, so there is no Esc to kill a runaway command — without /// this, a codedb refusal that pushes a subagent onto an unfiltered `grep ~/` @@ -81,9 +85,15 @@ fn startedText(gpa: Allocator, id: u32, cmd: []const u8, ssh: bool, auto_bg: boo return aw.toOwnedSlice(); } +fn defaultRootWaitMs() u64 { + const main_mod = @import("main.zig"); + if (main_mod.unattended and @import("no_local_tools.zig").lean) return lean_oneshot_wait_ms; + return root_wait_ms; +} + fn rootWaitMs(input: Value) u64 { - const t = intField(input, "timeout") orelse return root_wait_ms; - if (t <= 0) return root_wait_ms; + const t = intField(input, "timeout") orelse return defaultRootWaitMs(); + if (t <= 0) return defaultRootWaitMs(); return @min(@as(u64, @intCast(t)), job_wait.wait_cap_ms); } @@ -102,6 +112,27 @@ test "rootWaitMs: omitted/zero use 120s; positive values clamp to the 10h cap" { try std.testing.expectEqual(job_wait.wait_cap_ms, rootWaitMs(huge.value)); } +test "rootWaitMs: lean unattended oneshot defaults to 15s; timeout still wins" { + const main_mod = @import("main.zig"); + const nlt = @import("no_local_tools.zig"); + const saved_u = main_mod.unattended; + const saved_l = nlt.lean; + defer { + main_mod.unattended = saved_u; + nlt.lean = saved_l; + } + main_mod.unattended = true; + nlt.lean = true; + const empty = try std.json.parseFromSlice(Value, std.testing.allocator, "{}", .{}); + defer empty.deinit(); + try std.testing.expectEqual(lean_oneshot_wait_ms, rootWaitMs(empty.value)); + const custom = try std.json.parseFromSlice(Value, std.testing.allocator, "{\"timeout\":5000}", .{}); + defer custom.deinit(); + try std.testing.expectEqual(@as(u64, 5_000), rootWaitMs(custom.value)); + main_mod.unattended = false; + try std.testing.expectEqual(root_wait_ms, rootWaitMs(empty.value)); +} + fn formatJobDone(gpa: Allocator, cmd: []const u8, wait: jobs.FgDone) !ToolOutput { defer gpa.free(wait.output); var aw: Io.Writer.Allocating = .init(gpa); diff --git a/src/grok_spec_conformance.zig b/src/grok_spec_conformance.zig index 02d7f531..f0934cb7 100644 --- a/src/grok_spec_conformance.zig +++ b/src/grok_spec_conformance.zig @@ -148,9 +148,12 @@ test "grok spec: !live post wires Agent conv id; root and child differ" { var addr = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); var server = try std.Io.net.IpAddress.listen(&addr, io, .{}); var fut = io.async(Srv.run, .{ io, &server, &srv }); - defer fut.await(io); - defer server.deinit(io); var bound = server.socket.address; + defer { + if (std.Io.net.IpAddress.connect(&bound, io, .{ .mode = .stream })) |s| s.close(io) else |_| {} + fut.await(io); + server.deinit(io); + } var url_buf: [64]u8 = undefined; const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/chat", .{bound.getPort()}); var p_root = root.provider; diff --git a/src/help.zig b/src/help.zig index 75dd6d0f..037430ab 100644 --- a/src/help.zig +++ b/src/help.zig @@ -28,7 +28,7 @@ const peers_blurb = ; pub const sections = [_]Section{ - .{ .title = "getting around", .names = &.{ "/new", "/clear", "/resume", "/save", "/sessions", "/workspace", "/experiment", "/rename", "/rewind", "/snapshot" } }, + .{ .title = "getting around", .names = &.{ "/new", "/clear", "/resume", "/save", "/sessions", "/workspace", "/experiment", "/rename", "/rewind", "/snapshot", "/teleport" } }, .{ .title = "the model", .names = &.{ "/model", "/models", "/effort", "/reasoning", "/fast", "/thinking", "/keepcontext", "/fallback", "/routes" } }, .{ .title = "working autonomously", .names = &.{ "/goal", "/loop", "/schedule", "/review", "/plan", "/todo", "/jobs", "/ultracode", "/strict", "/yolo", "/never" } }, .{ .title = "talking to other graffs", .names = &.{ "/tell", "/peek", "/adapter" }, .blurb = peers_blurb }, diff --git a/src/http.zig b/src/http.zig index a6438ef0..2817e66c 100644 --- a/src/http.zig +++ b/src/http.zig @@ -16,15 +16,9 @@ const Provider = provider_mod.Provider; const Agent = agent_mod.Agent; const headers = @import("http_headers.zig"); const stall = @import("http_stall.zig"); // #56: the watchdogs' pure budget arithmetic - -/// Launch-scoped gate installed while the shared client's CA bundle warms in -/// the background. Null in unit tests and standalone pre-client subcommands. -pub var g_client_ready: ?*Io.Event = null; - -pub fn waitForClientReady(io: Io) void { - if (g_client_ready) |ready| ready.waitUncancelable(io); -} - +const http_client = @import("http_client.zig"); +pub const waitForClientReady = http_client.waitForReady; +pub const takeCaWarmFailure = http_client.takeCaWarmFailure; pub const providerUserAgent = headers.userAgent; pub const providerHeaders = headers.providerHeaders; /// Test/call-site seam: the !live request path's POST, with an explicit conv id. @@ -113,15 +107,16 @@ test "retryAfterMs: seconds, ms preferred, cap, HTTP-date/none -> 0 (#retry-afte /// POST the request body; returns the raw response body (caller frees). /// Built on client.request, NOT client.fetch: fetch never exposes the -/// Request, so a failed body send could not be poisoned — std re-pooled the -/// dead connection (a failed SEND leaves reader.state == .ready, which -/// Request.deinit reads as "still clean") and findConnection handed the same -/// corpse to every retry and every later same-host request, so one -/// WriteFailed became a whole-session storm across compaction, [title], and -/// subagents (#177). Mirrors postStream's errdefer poison (agent_stream.zig). -/// The client and its connection pool stay shared across pool threads — -/// client.request is what fetch wraps and is equally thread-safe. +/// Request, so a failed body send could not be poisoned and std re-pooled the +/// dead connection across later retries, compaction, titles, and subagents +/// (#177). Mirrors postStream's errdefer poison (agent_stream.zig). fn post(gpa: Allocator, client: *std.http.Client, provider: Provider, body: []const u8, conv_id: ?[]const u8) ![]u8 { + http_client.waitForReady(client.io); + var lease = http_client.acquire(client); + defer lease.release(); + if (!lease.available) return error.Canceled; + if (http_client.injectedConstructionTls(&lease)) |err| return err; + const transport = lease.client; var aw: Io.Writer.Allocating = .init(gpa); errdefer aw.deinit(); @@ -132,16 +127,19 @@ fn post(gpa: Allocator, client: *std.http.Client, provider: Provider, body: []co defer if (bearer.len > 0) gpa.free(bearer); var headers_buf: [12]std.http.Header = undefined; - const extra = headers.providerHeadersWithConv(client.io, provider, bearer, &headers_buf, conv_id); + const extra = headers.providerHeadersWithConv(transport.io, provider, bearer, &headers_buf, conv_id); - var req = try client.request(.POST, try std.Uri.parse(provider.url), .{ + var req = transport.request(.POST, try std.Uri.parse(provider.url), .{ .redirect_behavior = .unhandled, .headers = .{ .content_type = .{ .override = "application/json" }, .user_agent = providerUserAgent(provider), }, .extra_headers = extra, - }); + }) catch |err| { + if (http_client.constructionTlsFailure(err, provider.url)) return http_client.constructionTlsError(transport); + return err; + }; defer req.deinit(); // The #177 poison: on ANY error make deinit discard this connection // instead of returning it to the keep-alive pool, so the retry (and diff --git a/src/http_client.zig b/src/http_client.zig new file mode 100644 index 00000000..490dcc1c --- /dev/null +++ b/src/http_client.zig @@ -0,0 +1,406 @@ +//! Launch-level HTTP client generations for model traffic. +//! +//! A `TlsInitializationFailed` raised by `Client.request` occurs before a +//! Request exists, so the connection-poison cleanup in http.zig cannot touch +//! it. Model calls lease the active generation through this module. On that +//! construction error, one caller rotates the generation; in-flight callers +//! keep their old lease, and owned retired clients are reclaimed only after +//! their final lease is released. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const warm = @import("http_warm.zig"); + +const Generation = struct { + client: *std.http.Client, + id: u64, + refs: usize = 0, + retired: bool = false, + owned: bool, + next: ?*Generation = null, +}; + +pub const RecoveryOutcome = enum { + unavailable, + rotated, + rotated_ca_warm_failed, + already_rotated, +}; + +pub const Stats = struct { + active_id: u64, + active_refs: usize, + retired: usize, + total_refs: usize, + shutting_down: bool, +}; + +pub const Recovery = struct { + gpa: Allocator, + io: Io, + mutex: Io.Mutex = .init, + idle: Io.Condition = .init, + original: Generation, + active: *Generation, + retired: ?*Generation = null, + next_id: u64 = 1, + total_refs: usize = 0, + shutting_down: bool = false, + warm_replacements: bool, + + pub fn init(self: *Recovery, gpa: Allocator, io: Io, original: *std.http.Client, warm_replacements: bool) void { + self.* = .{ + .gpa = gpa, + .io = io, + .original = .{ .client = original, .id = 0, .owned = false }, + .active = undefined, + .warm_replacements = warm_replacements, + }; + self.active = &self.original; + } + + pub fn beginShutdown(self: *Recovery) void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + self.shutting_down = true; + } + + pub fn shutdown(self: *Recovery) void { + self.beginShutdown(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + while (self.total_refs != 0) self.idle.waitUncancelable(self.io, &self.mutex); + } + + pub fn deinit(self: *Recovery) void { + self.shutdown(); + std.debug.assert(self.active.refs == 0); + if (self.active.owned) self.destroyOwned(self.active); + var cursor = self.retired; + while (cursor) |generation| { + const next = generation.next; + std.debug.assert(generation.refs == 0); + if (generation.owned) self.destroyOwned(generation); + cursor = next; + } + self.retired = null; + } + + pub fn acquire(self: *Recovery, requested: *std.http.Client) Lease { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + if (requested != self.original.client and requested != self.active.client) + return .{ .client = requested }; + if (self.shutting_down) return .{ .client = requested, .available = false }; + self.active.refs += 1; + self.total_refs += 1; + return .{ .client = self.active.client, .owner = self, .generation = self.active, .generation_id = self.active.id }; + } + + pub fn stats(self: *Recovery) Stats { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + var retired: usize = 0; + var cursor = self.retired; + while (cursor) |generation| : (cursor = generation.next) retired += 1; + return .{ + .active_id = self.active.id, + .active_refs = self.active.refs, + .retired = retired, + .total_refs = self.total_refs, + .shutting_down = self.shutting_down, + }; + } + + /// Retire the generation that failed while constructing a request. If a + /// concurrent caller already rotated it, this is a successful no-op: the + /// next retry will acquire that newer generation. + pub fn recoverConstructionTls(self: *Recovery, failed: *std.http.Client) RecoveryOutcome { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + if (self.shutting_down) return .unavailable; + if (failed != self.active.client) + return if (failed == self.original.client or self.isRetired(failed)) .already_rotated else .unavailable; + + const client = self.gpa.create(std.http.Client) catch return .unavailable; + client.* = .{ .allocator = self.gpa, .io = self.io }; + var ca_warm_failed = false; + if (self.warm_replacements) { + if (builtin.is_test and g_test_fail_ca_warm.swap(false, .acq_rel)) { + ca_warm_failed = true; + } else warm.prewarmCaBundle(client, self.gpa, self.io) catch { + ca_warm_failed = true; + }; + } + if (builtin.is_test and g_test_fail_generation_alloc.swap(false, .acq_rel)) { + client.deinit(); + self.gpa.destroy(client); + return .unavailable; + } + const generation = self.gpa.create(Generation) catch { + client.deinit(); + self.gpa.destroy(client); + return .unavailable; + }; + generation.* = .{ .client = client, .id = self.next_id, .owned = true }; + self.next_id += 1; + + const old = self.active; + old.retired = true; + old.next = self.retired; + self.retired = old; + self.active = generation; + if (old.refs == 0) self.reclaim(old); + return if (ca_warm_failed) .rotated_ca_warm_failed else .rotated; + } + + fn release(self: *Recovery, generation: *Generation) void { + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + std.debug.assert(generation.refs > 0); + generation.refs -= 1; + self.total_refs -= 1; + if (generation.retired and generation.refs == 0) self.reclaim(generation); + if (self.shutting_down and self.total_refs == 0) self.idle.broadcast(self.io); + } + + fn isRetired(self: *Recovery, client: *std.http.Client) bool { + var cursor = self.retired; + while (cursor) |generation| : (cursor = generation.next) { + if (generation.client == client) return true; + } + return false; + } + + fn reclaim(self: *Recovery, target: *Generation) void { + var link = &self.retired; + while (link.*) |generation| { + if (generation == target) { + link.* = generation.next; + generation.next = null; + generation.retired = false; + if (generation.owned) self.destroyOwned(generation); + return; + } + link = &generation.next; + } + } + + fn destroyOwned(self: *Recovery, generation: *Generation) void { + generation.client.deinit(); + self.gpa.destroy(generation.client); + self.gpa.destroy(generation); + } +}; + +pub const Lease = struct { + client: *std.http.Client, + owner: ?*Recovery = null, + generation: ?*Generation = null, + generation_id: u64 = 0, + available: bool = true, + + pub fn release(self: *Lease) void { + if (self.owner) |owner| owner.release(self.generation.?); + self.* = .{ .client = self.client }; + } +}; + +var g_lifecycle_mutex: Io.Mutex = .init; +var g_lifecycle_idle: Io.Condition = .init; +var g_ready_waiters: usize = 0; +var g_closing: bool = false; +var g_recovery: ?*Recovery = null; +var g_client_ready: ?*Io.Event = null; +var g_closed_client: ?*std.http.Client = null; +var g_test_wait_entered: ?*Io.Event = null; +var g_ca_warm_failed: std.atomic.Value(bool) = .init(false); +const no_test_failure = std.math.maxInt(u64); +var g_test_fail_generation: std.atomic.Value(u64) = .init(no_test_failure); +var g_test_fail_through_generation: std.atomic.Value(u64) = .init(no_test_failure); +var g_test_fail_ca_warm: std.atomic.Value(bool) = .init(false); +var g_test_fail_generation_alloc: std.atomic.Value(bool) = .init(false); +var g_test_tls_arrivals: ?*std.atomic.Value(usize) = null; +var g_test_tls_all_arrived: ?*Io.Event = null; +var g_test_tls_release: ?*Io.Event = null; + +pub fn acquire(requested: *std.http.Client) Lease { + g_lifecycle_mutex.lockUncancelable(requested.io); + defer g_lifecycle_mutex.unlock(requested.io); + if (g_recovery) |recovery| return recovery.acquire(requested); + if (g_closed_client == requested) return .{ .client = requested, .available = false }; + return .{ .client = requested }; +} + +/// True when a `transport.request()` construction error must traverse the +/// TLS recovery path: the handshake failed outright — or (Windows) died +/// mid-read, where NTSTATUS LOCAL_DISCONNECT surfaces as error.Unexpected +/// out of the handshake read instead of TlsInitializationFailed. Gated to +/// https so a plain-HTTP connect error is never mislabeled a TLS failure. +pub fn constructionTlsFailure(err: anyerror, url: []const u8) bool { + return err == error.TlsInitializationFailed or + (err == error.Unexpected and std.mem.startsWith(u8, url, "https://")); +} + +pub fn constructionTlsError(failed: *std.http.Client) anyerror { + g_lifecycle_mutex.lockUncancelable(failed.io); + defer g_lifecycle_mutex.unlock(failed.io); + const outcome = if (g_recovery) |recovery| recovery.recoverConstructionTls(failed) else .unavailable; + return switch (outcome) { + .unavailable => error.TlsInitializationFailed, + .rotated_ca_warm_failed => error.TlsRequestConstructionCaWarmFailed, + .rotated, .already_rotated => error.TlsRequestConstructionFailed, + }; +} + +pub fn injectConstructionTlsForTest(generation: u64) void { + if (builtin.is_test) g_test_fail_generation.store(generation, .release); +} + +pub fn injectConstructionTlsThroughGenerationForTest(last_generation: u64) void { + if (builtin.is_test) g_test_fail_through_generation.store(last_generation, .release); +} + +pub fn injectReplacementCaWarmFailureForTest() void { + if (builtin.is_test) g_test_fail_ca_warm.store(true, .release); +} + +pub fn injectGenerationAllocationFailureForTest() void { + if (builtin.is_test) g_test_fail_generation_alloc.store(true, .release); +} + +pub fn injectLaunchCaWarmFailureForTest() void { + if (builtin.is_test) g_ca_warm_failed.store(true, .release); +} + +pub fn installConstructionTlsBarrierForTest(arrivals: *std.atomic.Value(usize), all_arrived: *Io.Event, release: *Io.Event) void { + if (!builtin.is_test) return; + g_test_tls_arrivals = arrivals; + g_test_tls_all_arrived = all_arrived; + g_test_tls_release = release; +} + +inline fn resetTestHooks() void { + if (comptime !builtin.is_test) return; + g_test_wait_entered = null; + g_test_fail_generation.store(no_test_failure, .release); + g_test_fail_through_generation.store(no_test_failure, .release); + g_test_fail_ca_warm.store(false, .release); + g_test_fail_generation_alloc.store(false, .release); + g_test_tls_arrivals = null; + g_test_tls_all_arrived = null; + g_test_tls_release = null; +} + +pub fn installForTest(recovery: *Recovery, ready: ?*Io.Event, wait_entered: ?*Io.Event) void { + if (!builtin.is_test) return; + g_lifecycle_mutex.lockUncancelable(recovery.io); + defer g_lifecycle_mutex.unlock(recovery.io); + g_closing = false; + g_closed_client = null; + g_recovery = recovery; + g_client_ready = ready; + resetTestHooks(); + g_test_wait_entered = wait_entered; + g_ca_warm_failed.store(false, .release); +} + +pub fn uninstallForTest() void { + if (!builtin.is_test) return; + const recovery = g_recovery orelse return; + g_lifecycle_mutex.lockUncancelable(recovery.io); + defer g_lifecycle_mutex.unlock(recovery.io); + g_closing = true; + while (g_ready_waiters != 0) g_lifecycle_idle.waitUncancelable(recovery.io, &g_lifecycle_mutex); + g_recovery = null; + g_client_ready = null; + g_closed_client = null; + g_closing = false; + resetTestHooks(); +} + +pub inline fn injectedConstructionTls(lease: *const Lease) ?anyerror { + if (comptime !builtin.is_test) return null; + if (lease.owner == null) return null; + const through = g_test_fail_through_generation.load(.acquire); + const should_fail = through != no_test_failure and lease.generation_id <= through or + g_test_fail_generation.cmpxchgStrong(lease.generation_id, no_test_failure, .acq_rel, .acquire) == null; + if (!should_fail) return null; + if (g_test_tls_arrivals) |arrivals| { + if (arrivals.fetchAdd(1, .acq_rel) + 1 == 2) g_test_tls_all_arrived.?.set(lease.owner.?.io); + g_test_tls_release.?.waitUncancelable(lease.owner.?.io); + } + return constructionTlsError(lease.client); +} + +pub fn waitForReady(io: Io) void { + g_lifecycle_mutex.lockUncancelable(io); + if (g_closing or g_client_ready == null) { + g_lifecycle_mutex.unlock(io); + return; + } + const ready = g_client_ready.?; + g_ready_waiters += 1; + g_lifecycle_mutex.unlock(io); + + if (comptime builtin.is_test) if (g_test_wait_entered) |entered| entered.set(io); + ready.waitUncancelable(io); + + g_lifecycle_mutex.lockUncancelable(io); + g_ready_waiters -= 1; + if (g_closing and g_ready_waiters == 0) g_lifecycle_idle.broadcast(io); + g_lifecycle_mutex.unlock(io); +} + +pub fn takeCaWarmFailure() bool { + return g_ca_warm_failed.swap(false, .acq_rel); +} + +pub const Runtime = struct { + gpa: Allocator, + io: Io, + client: std.http.Client, + ready: Io.Event, + warm_future: Io.Future(void), + recovery: Recovery, + + pub fn init(self: *Runtime, gpa: Allocator, io: Io) void { + self.gpa = gpa; + self.io = io; + self.client = .{ .allocator = gpa, .io = io }; + self.ready = .unset; + self.recovery.init(gpa, io, &self.client, true); + + g_lifecycle_mutex.lockUncancelable(io); + g_closing = false; + g_closed_client = null; + g_recovery = &self.recovery; + g_client_ready = &self.ready; + resetTestHooks(); + g_ca_warm_failed.store(false, .release); + g_lifecycle_mutex.unlock(io); + self.warm_future = io.async(warm.prewarmCaBundleTask, .{ &self.client, gpa, io, &self.ready, &g_ca_warm_failed }); + } + + pub fn deinit(self: *Runtime, await_io: Io) void { + g_lifecycle_mutex.lockUncancelable(self.io); + g_closing = true; + self.recovery.beginShutdown(); + while (g_ready_waiters != 0) g_lifecycle_idle.waitUncancelable(self.io, &g_lifecycle_mutex); + g_lifecycle_mutex.unlock(self.io); + + self.recovery.shutdown(); + _ = self.warm_future.await(await_io); + + g_lifecycle_mutex.lockUncancelable(self.io); + g_client_ready = null; + g_recovery = null; + g_closed_client = &self.client; + resetTestHooks(); + self.recovery.deinit(); + self.client.deinit(); + g_lifecycle_mutex.unlock(self.io); + } +}; diff --git a/src/http_client_integration_tests.zig b/src/http_client_integration_tests.zig new file mode 100644 index 00000000..74624e6d --- /dev/null +++ b/src/http_client_integration_tests.zig @@ -0,0 +1,546 @@ +//! End-to-end model-transport regressions for request-construction TLS recovery. + +const std = @import("std"); +const Io = std.Io; +const Agent = @import("agent.zig").Agent; +const http = @import("http.zig"); +const http_client = @import("http_client.zig"); +const mock = @import("agent_ws_mock.zig"); +const trace = @import("trace.zig"); +const Provider = @import("provider.zig").Provider; +const Approvals = @import("approvals.zig").Approvals; +const repl_turn = @import("repl_turn.zig"); +const subagent_run = @import("subagent_run.zig"); +const tools = @import("tools.zig"); + +pub const Reply = struct { + status: []const u8 = "200 OK", + content_type: []const u8 = "application/json", + body: []const u8, +}; + +pub const chat_body = + \\{"choices":[{"index":0,"message":{"role":"assistant","content":"child-ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} +; +const sse_body = + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"root-ok\"}\n\n" ++ + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n"; +const chat_sse_body = + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"root-ok\"},\"finish_reason\":null}]}\n\n" ++ + "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" ++ + "data: [DONE]\n\n"; + +fn readRequest(reader: *std.Io.net.Stream.Reader) !void { + var content_length: usize = 0; + while (true) { + const line = (try reader.interface.takeDelimiter('\n')) orelse return error.EndOfStream; + if (line.len == 0 or (line.len == 1 and line[0] == '\r')) break; + if (std.ascii.startsWithIgnoreCase(line, "content-length:")) { + content_length = try std.fmt.parseInt(usize, std.mem.trim(u8, line[15..], " \t\r"), 10); + } + } + try reader.interface.discardAll(content_length); +} + +pub fn serveReplies(io: Io, server: *std.Io.net.Server, replies: []const Reply, accepted: *std.atomic.Value(usize)) void { + for (replies) |reply| { + const conn = server.accept(io) catch return; + { + defer conn.close(io); + _ = accepted.fetchAdd(1, .acq_rel); + var read_buf: [16 * 1024]u8 = undefined; + var reader = std.Io.net.Stream.Reader.init(conn, io, &read_buf); + readRequest(&reader) catch return; + var head_buf: [256]u8 = undefined; + const head = std.fmt.bufPrint( + &head_buf, + "HTTP/1.1 {s}\r\ncontent-type: {s}\r\ncontent-length: {d}\r\nconnection: close\r\n\r\n", + .{ reply.status, reply.content_type, reply.body.len }, + ) catch return; + var write_buf: [4096]u8 = undefined; + var writer = std.Io.net.Stream.Writer.init(conn, io, &write_buf); + writer.interface.writeAll(head) catch return; + writer.interface.writeAll(reply.body) catch return; + writer.interface.flush() catch return; + } + } +} + +fn serveInvalidTls(io: Io, server: *std.Io.net.Server, count: usize, accepted: *std.atomic.Value(usize)) void { + for (0..count) |_| { + const conn = server.accept(io) catch return; + defer conn.close(io); + _ = accepted.fetchAdd(1, .acq_rel); + var write_buf: [128]u8 = undefined; + var writer = std.Io.net.Stream.Writer.init(conn, io, &write_buf); + writer.interface.writeAll("HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n") catch continue; + writer.interface.flush() catch continue; + } +} + +pub fn releaseAccept(io: Io, server: *std.Io.net.Server) void { + const address = server.socket.address; + if (std.Io.net.IpAddress.connect(&address, io, .{ .mode = .stream })) |stream| stream.close(io) else |_| {} +} + +pub fn provider(url: []const u8) Provider { + return .{ + .id = "test", + .kind = .openai, + .auth = .x_api_key, + .url = url, + .api_key = "test", + .model = "test", + .context = 100_000, + }; +} + +fn childAgent(gpa: std.mem.Allocator, arena: std.mem.Allocator, io: Io, client: *std.http.Client, p: Provider) Agent { + return .{ + .gpa = gpa, + .arena = arena, + .io = io, + .client = client, + .provider = p, + .messages = std.json.Array.init(arena), + .sub = true, + .label = "test-child", + .out = null, + }; +} + +fn postTask(gpa: std.mem.Allocator, client: *std.http.Client, p: Provider) anyerror![]u8 { + return http.postWithConv(gpa, client, p, "{}", null); +} + +fn postWatchedTask(gpa: std.mem.Allocator, io: Io, client: *std.http.Client, p: Provider) anyerror![]u8 { + return http.postWatched(gpa, io, client, p, "{}", null); +} + +test "real malformed TLS handshakes traverse both production constructor catches" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveInvalidTls, .{ io, &server, 2, &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "https://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + try std.testing.expectError( + error.TlsRequestConstructionFailed, + http.postWithConv(gpa, &runtime.client, provider(url), "{}", null), + ); + try std.testing.expectEqual(@as(u64, 1), runtime.recovery.stats().active_id); + + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var root = childAgent(gpa, arena_state.allocator(), io, &runtime.client, provider(url)); + root.sub = false; + try std.testing.expectError(error.TlsRequestConstructionFailed, root.postStreamWithClient(&runtime.client, "{}")); + try std.testing.expectEqual(@as(u64, 2), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); +} + +test "TUI turn agent and actual runSub child share the recovered generation" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{ + .{ .content_type = "text/event-stream", .body = chat_sse_body }, + .{ .body = chat_body }, + }; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + const p = provider(url); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var output: Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + var approvals: Approvals = .{ .yolo = true }; + var ctx = repl_turn.testCtx(&runtime.client); + ctx.provider = p; + var root = try repl_turn.turnAgent(&ctx, gpa, arena_state.allocator(), .{}, &output.writer, &approvals); + defer root.tools_used.deinit(gpa); + + http_client.injectConstructionTlsForTest(0); + _ = try root.request(null); + try std.testing.expect(std.mem.indexOf(u8, output.written(), "root-ok") != null); + try std.testing.expectEqual(@as(u64, 1), runtime.recovery.stats().active_id); + + const tool_ctx: tools.ToolCtx = .{ + .gpa = gpa, + .io = io, + .client = &runtime.client, + .provider = p, + .registry = null, + .from_sub = false, + .approvals = &approvals, + .tracer = null, + }; + const child = try subagent_run.runSub(tool_ctx, "subagent", "tls-test-child", "reply once", "test child", "", .shared_cwd, false, p, null); + defer gpa.free(child.output.text); + try std.testing.expect(!child.output.is_error); + try std.testing.expect(std.mem.indexOf(u8, child.output.text, "child-ok") != null); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); +} + +test "failed streaming root generation recovers later root and child requests" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{ + .{ .content_type = "text/event-stream", .body = sse_body }, + .{ .body = chat_body }, + }; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var root = mock.mockAgent(gpa, arena, io, url); + root.client = &runtime.client; + http_client.injectConstructionTlsForTest(0); + try std.testing.expectError(error.TlsRequestConstructionFailed, root.postStreamWithClient(root.client, "{}")); + + const streamed = try root.postStreamWithClient(root.client, "{}"); + defer gpa.free(streamed); + try std.testing.expect(std.mem.indexOf(u8, streamed, "response.completed") != null); + + var child = childAgent(gpa, arena, io, &runtime.client, provider(url)); + _ = try child.request(null); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); + const stats = runtime.recovery.stats(); + try std.testing.expectEqual(@as(u64, 1), stats.active_id); + try std.testing.expectEqual(@as(usize, 0), stats.active_refs); + try std.testing.expectEqual(@as(usize, 0), stats.retired); +} + +test "child retry ladder recovers within the same request" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{.{ .body = chat_body }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var child = childAgent(gpa, arena_state.allocator(), io, &runtime.client, provider(url)); + + http_client.injectConstructionTlsForTest(0); + _ = try child.request(null); + try std.testing.expectEqual(@as(usize, 1), accepted.load(.acquire)); + try std.testing.expectEqual(@as(u64, 1), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); +} + +test "later root and child recover after the retry ladder exhausts TLS generations" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{ + .{ .content_type = "text/event-stream", .body = chat_sse_body }, + .{ .body = chat_body }, + }; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var output: Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + var trace_output: Io.Writer.Allocating = .init(gpa); + defer trace_output.deinit(); + var tracer: trace.Tracer = .{ .io = io, .gpa = gpa, .out = &trace_output.writer, .start = Io.Timestamp.now(io, .awake) }; + + var root = childAgent(gpa, arena, io, &runtime.client, provider(url)); + root.sub = false; + root.label = "test-root"; + root.out = &output.writer; + root.tracer = &tracer; + http_client.injectConstructionTlsThroughGenerationForTest(5); + try std.testing.expectError(error.ApiError, root.request(null)); + try std.testing.expectEqual(@as(usize, 0), accepted.load(.acquire)); + try std.testing.expectEqual(@as(u64, 6), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 6), std.mem.count(u8, trace_output.written(), "\"ev\":\"tls_request_construction\"")); + try std.testing.expect(std.mem.indexOf(u8, root.last_api_error.?, "gave up after 6 attempts") != null); + + _ = try root.request(null); + try std.testing.expect(std.mem.indexOf(u8, output.written(), "root-ok") != null); + var child = childAgent(gpa, arena, io, &runtime.client, provider(url)); + const child_response = try child.request(null); + const choices = child_response.get("choices").?.array.items; + const content = choices[0].object.get("message").?.object.get("content").?.string; + try std.testing.expectEqualStrings("child-ok", content); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().retired); +} + +test "repeated TLS failures rotate multiple generations before recovery" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{.{ .body = "ok" }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + const p = provider(url); + http_client.injectConstructionTlsForTest(0); + try std.testing.expectError(error.TlsRequestConstructionFailed, http.postWithConv(gpa, &runtime.client, p, "{}", null)); + http_client.injectConstructionTlsForTest(1); + try std.testing.expectError(error.TlsRequestConstructionFailed, http.postWithConv(gpa, &runtime.client, p, "{}", null)); + const recovered = try http.postWithConv(gpa, &runtime.client, p, "{}", null); + defer gpa.free(recovered); + try std.testing.expectEqualStrings("ok", recovered); + try std.testing.expectEqual(@as(u64, 2), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().retired); +} + +test "simultaneous callers survive one shared generation failure" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{.{ .body = "ok" }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + const p = provider(url); + var arrivals: std.atomic.Value(usize) = .init(0); + var all_arrived: Io.Event = .unset; + var release: Io.Event = .unset; + http_client.installConstructionTlsBarrierForTest(&arrivals, &all_arrived, &release); + http_client.injectConstructionTlsThroughGenerationForTest(0); + var first = io.async(postTask, .{ gpa, &runtime.client, p }); + var second = io.async(postTask, .{ gpa, &runtime.client, p }); + all_arrived.waitUncancelable(io); + try std.testing.expectEqual(@as(usize, 2), runtime.recovery.stats().active_refs); + release.set(io); + const results = [_]anyerror![]u8{ first.await(io), second.await(io) }; + for (results) |result| { + if (result) |body| { + gpa.free(body); + return error.UnexpectedSuccess; + } else |err| try std.testing.expectEqual(error.TlsRequestConstructionFailed, err); + } + try std.testing.expectEqual(@as(u64, 1), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().retired); + + const recovered = try http.postWithConv(gpa, &runtime.client, p, "{}", null); + defer gpa.free(recovered); + try std.testing.expectEqualStrings("ok", recovered); + try std.testing.expectEqual(@as(usize, 1), accepted.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); +} + +test "direct model POST waits for CA readiness before dialing" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: http_client.Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + var ready: Io.Event = .unset; + var wait_entered: Io.Event = .unset; + http_client.installForTest(&recovery, &ready, &wait_entered); + defer http_client.uninstallForTest(); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{.{ .body = "ok" }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/compact", .{server.socket.address.getPort()}); + var posted = io.async(postWatchedTask, .{ gpa, io, &original, provider(url) }); + wait_entered.waitUncancelable(io); + try std.testing.expectEqual(@as(usize, 0), accepted.load(.acquire)); + ready.set(io); + const body = try posted.await(io); + defer gpa.free(body); + try std.testing.expectEqualStrings("ok", body); + try std.testing.expectEqual(@as(usize, 1), accepted.load(.acquire)); +} + +test "launch CA failure trace is consumed once and does not block model requests" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{ .{ .body = chat_body }, .{ .body = chat_body } }; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var trace_output: Io.Writer.Allocating = .init(gpa); + defer trace_output.deinit(); + var tracer: trace.Tracer = .{ .io = io, .gpa = gpa, .out = &trace_output.writer, .start = Io.Timestamp.now(io, .awake) }; + var child = childAgent(gpa, arena_state.allocator(), io, &runtime.client, provider(url)); + child.tracer = &tracer; + + http_client.injectLaunchCaWarmFailureForTest(); + _ = try child.request(null); + _ = try child.request(null); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, trace_output.written(), "\"ev\":\"ca_prewarm_failed\"")); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); +} + +test "Agent request traces replacement CA failure and recovers in the same retry ladder" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{ .{ .body = chat_body }, .{ .body = chat_body } }; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var trace_output: Io.Writer.Allocating = .init(gpa); + defer trace_output.deinit(); + var tracer: trace.Tracer = .{ .io = io, .gpa = gpa, .out = &trace_output.writer, .start = Io.Timestamp.now(io, .awake) }; + var child = childAgent(gpa, arena_state.allocator(), io, &runtime.client, provider(url)); + child.tracer = &tracer; + + http_client.injectReplacementCaWarmFailureForTest(); + http_client.injectConstructionTlsForTest(0); + _ = try child.request(null); + try std.testing.expect(std.mem.indexOf( + u8, + trace_output.written(), + "rotated shared HTTP client generation; replacement CA prewarm failed", + ) != null); + _ = try child.request(null); + try std.testing.expectEqual(@as(usize, 2), accepted.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); +} + +test "HTTP response error releases its generation lease" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http.waitForClientReady(io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]Reply{.{ .status = "500 Internal Server Error", .body = "upstream failed\n" }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(serveReplies, .{ io, &server, @as([]const Reply, &replies), &accepted }); + defer server_future.await(io); + defer releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + try std.testing.expectError(error.ServerError, http.postWithConv(gpa, &runtime.client, provider(url), "{}", null)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().retired); +} diff --git a/src/http_client_tests.zig b/src/http_client_tests.zig new file mode 100644 index 00000000..2fd652a1 --- /dev/null +++ b/src/http_client_tests.zig @@ -0,0 +1,294 @@ +//! Unit tests for recoverable HTTP client generation lifetime and fault hooks. + +const std = @import("std"); +const http_client = @import("http_client.zig"); +const Recovery = http_client.Recovery; +const RecoveryOutcome = http_client.RecoveryOutcome; + +fn recoverTask(recovery: *Recovery, client: *std.http.Client) RecoveryOutcome { + return recovery.recoverConstructionTls(client); +} + +fn shutdownTask(recovery: *Recovery) void { + recovery.shutdown(); +} + +test "runtime teardown leaves the global transport closed to late acquisitions" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + runtime.deinit(io); + + var late = http_client.acquire(&runtime.client); + defer late.release(); + try std.testing.expect(!late.available); + + var unrelated: std.http.Client = .{ .allocator = gpa, .io = io }; + defer unrelated.deinit(); + var unmanaged = http_client.acquire(&unrelated); + defer unmanaged.release(); + try std.testing.expect(unmanaged.available); +} + +fn runtimeDeinitTask(runtime: *http_client.Runtime, io: std.Io) void { + runtime.deinit(io); +} + +test "runtime teardown closes admission before draining an active lease" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + http_client.waitForReady(io); + var lease = http_client.acquire(&runtime.client); + try std.testing.expect(lease.available); + + var teardown = io.async(runtimeDeinitTask, .{ &runtime, io }); + for (0..100) |_| { + if (runtime.recovery.stats().shutting_down) break; + try io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expect(runtime.recovery.stats().shutting_down); + var denied = http_client.acquire(&runtime.client); + defer denied.release(); + try std.testing.expect(!denied.available); + + lease.release(); + _ = teardown.await(io); +} + +fn waitReadyTask(io: std.Io) void { + http_client.waitForReady(io); +} + +fn uninstallTask() void { + http_client.uninstallForTest(); +} + +test "lifecycle teardown drains callers already waiting for CA readiness" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + var ready: std.Io.Event = .unset; + var wait_entered: std.Io.Event = .unset; + http_client.installForTest(&recovery, &ready, &wait_entered); + + var waiter = io.async(waitReadyTask, .{io}); + wait_entered.waitUncancelable(io); + var teardown = io.async(uninstallTask, .{}); + ready.set(io); + _ = waiter.await(io); + _ = teardown.await(io); +} + +test "retired generation stays alive until its final concurrent lease releases" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var first = recovery.acquire(&original); + var second = recovery.acquire(&original); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(first.client)); + try std.testing.expectEqual(@as(usize, 1), recovery.stats().retired); + first.release(); + try std.testing.expectEqual(@as(usize, 1), recovery.stats().retired); + second.release(); + try std.testing.expectEqual(@as(usize, 0), recovery.stats().retired); +} + +test "replacement allocation failures keep the current generation usable" { + const backing = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = backing, .io = io }; + defer original.deinit(); + + for (0..2) |fail_index| { + var failing = std.testing.FailingAllocator.init(backing, .{ .fail_index = fail_index }); + var recovery: Recovery = undefined; + recovery.init(failing.allocator(), io, &original, false); + defer recovery.deinit(); + var lease = recovery.acquire(&original); + defer lease.release(); + try std.testing.expectEqual(RecoveryOutcome.unavailable, recovery.recoverConstructionTls(lease.client)); + try std.testing.expectEqual(@as(u64, 0), recovery.stats().active_id); + } +} + +test "unmanaged client TLS failure does not rotate the launch generation" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var unrelated: std.http.Client = .{ .allocator = gpa, .io = io }; + defer unrelated.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + try std.testing.expectEqual(RecoveryOutcome.unavailable, recovery.recoverConstructionTls(&unrelated)); + try std.testing.expectEqual(@as(u64, 0), recovery.stats().active_id); +} + +test "replacement CA warm failure is attributed to the triggering rotation" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, true); + defer recovery.deinit(); + + var failed = recovery.acquire(&original); + defer failed.release(); + http_client.injectReplacementCaWarmFailureForTest(); + try std.testing.expectEqual(RecoveryOutcome.rotated_ca_warm_failed, recovery.recoverConstructionTls(failed.client)); +} + +test "request-construction TLS recovery reaches later root and child trajectories" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var failed_root = recovery.acquire(&original); + try std.testing.expectEqual(@as(u64, 0), failed_root.generation_id); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(failed_root.client)); + + var later_root = recovery.acquire(&original); + defer later_root.release(); + var child = recovery.acquire(&original); + defer child.release(); + try std.testing.expectEqual(@as(u64, 1), later_root.generation_id); + try std.testing.expectEqual(later_root.generation_id, child.generation_id); + try std.testing.expect(later_root.client == child.client); + try std.testing.expect(later_root.client != failed_root.client); + failed_root.release(); +} + +test "concurrent stale TLS reports rotate a generation only once" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var first = recovery.acquire(&original); + var concurrent = recovery.acquire(&original); + var first_fut = io.async(recoverTask, .{ &recovery, first.client }); + var second_fut = io.async(recoverTask, .{ &recovery, concurrent.client }); + const first_result = first_fut.await(io); + const second_result = second_fut.await(io); + try std.testing.expect(first_result != .unavailable); + try std.testing.expect(second_result != .unavailable); + try std.testing.expect(first_result != second_result); + var after = recovery.acquire(&original); + defer after.release(); + try std.testing.expectEqual(@as(u64, 1), after.generation_id); + first.release(); + concurrent.release(); +} + +test "shutdown rejects new managed acquisitions and drains an in-flight replacement" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var initial = recovery.acquire(&original); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(initial.client)); + initial.release(); + var in_flight = recovery.acquire(&original); + try std.testing.expectEqual(@as(u64, 1), in_flight.generation_id); + + var shutdown = io.async(shutdownTask, .{&recovery}); + for (0..100) |_| { + if (recovery.stats().shutting_down) break; + try io.sleep(.fromMilliseconds(1), .awake); + } + try std.testing.expect(recovery.stats().shutting_down); + const denied = recovery.acquire(&original); + try std.testing.expect(!denied.available); + try std.testing.expectEqual(@as(usize, 1), recovery.stats().total_refs); + + in_flight.release(); + _ = shutdown.await(io); + try std.testing.expectEqual(@as(usize, 0), recovery.stats().total_refs); +} + +test "owned retired client remains alive until both stale leases release" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + + var initial = recovery.acquire(&original); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(initial.client)); + initial.release(); + var first = recovery.acquire(&original); + var second = recovery.acquire(&original); + try std.testing.expectEqual(RecoveryOutcome.rotated, recovery.recoverConstructionTls(first.client)); + try std.testing.expectEqual(@as(usize, 1), recovery.stats().retired); + first.release(); + try std.testing.expectEqual(@as(usize, 1), recovery.stats().retired); + second.release(); + try std.testing.expectEqual(@as(usize, 0), recovery.stats().retired); +} + +test "generation zero injection never intercepts an unmanaged client" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var unrelated: std.http.Client = .{ .allocator = gpa, .io = io }; + defer unrelated.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, false); + defer recovery.deinit(); + http_client.installForTest(&recovery, null, null); + defer http_client.uninstallForTest(); + + http_client.injectConstructionTlsForTest(0); + var unmanaged = recovery.acquire(&unrelated); + defer unmanaged.release(); + try std.testing.expect(http_client.injectedConstructionTls(&unmanaged) == null); + var managed = recovery.acquire(&original); + defer managed.release(); + try std.testing.expectEqual(error.TlsRequestConstructionFailed, http_client.injectedConstructionTls(&managed).?); +} + +test "post-prewarm generation allocation failure cleans up and preserves active client" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var original: std.http.Client = .{ .allocator = gpa, .io = io }; + defer original.deinit(); + var recovery: Recovery = undefined; + recovery.init(gpa, io, &original, true); + defer recovery.deinit(); + + var lease = recovery.acquire(&original); + defer lease.release(); + http_client.injectGenerationAllocationFailureForTest(); + try std.testing.expectEqual(RecoveryOutcome.unavailable, recovery.recoverConstructionTls(lease.client)); + try std.testing.expectEqual(@as(u64, 0), recovery.stats().active_id); + try std.testing.expect(lease.client == &original); +} diff --git a/src/http_client_trajectory_tests.zig b/src/http_client_trajectory_tests.zig new file mode 100644 index 00000000..5c25993c --- /dev/null +++ b/src/http_client_trajectory_tests.zig @@ -0,0 +1,60 @@ +//! Production-shaped model-call trajectories that sit above the transport adapter. + +const std = @import("std"); +const Io = std.Io; +const http_client = @import("http_client.zig"); +const support = @import("http_client_integration_tests.zig"); +const subagent = @import("subagent.zig"); +const tools = @import("tools.zig"); + +test "background subagent tool recovers TLS and completes through agent_output" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var runtime: http_client.Runtime = undefined; + runtime.init(gpa, io); + defer runtime.deinit(io); + http_client.waitForReady(io); + defer subagent.agentJobsReap(gpa, io); + + var address = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try std.Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + const replies = [_]support.Reply{.{ .body = support.chat_body }}; + var accepted: std.atomic.Value(usize) = .init(0); + var server_future = io.async(support.serveReplies, .{ io, &server, @as([]const support.Reply, &replies), &accepted }); + defer server_future.await(io); + defer support.releaseAccept(io, &server); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/test", .{server.socket.address.getPort()}); + const ctx: tools.ToolCtx = .{ + .gpa = gpa, + .io = io, + .client = &runtime.client, + .provider = support.provider(url), + .registry = null, + .from_sub = false, + .approvals = null, + .tracer = null, + }; + const parsed = try std.json.parseFromSlice(std.json.Value, gpa, + \\{"description":"tls-background-child","prompt":"reply once","run_in_background":true} + , .{}); + defer parsed.deinit(); + + http_client.injectConstructionTlsForTest(0); + const spawned = try subagent.execSubagent(ctx, parsed.value); + defer gpa.free(spawned.text); + try std.testing.expect(!spawned.is_error); + const id_start = std.mem.indexOf(u8, spawned.text, "[agent ").? + "[agent ".len; + const id_end = std.mem.indexOfScalarPos(u8, spawned.text, id_start, ' ').?; + const id = try std.fmt.parseInt(u32, spawned.text[id_start..id_end], 10); + + const completed = try subagent.agentOutput(gpa, io, id, 1); + defer gpa.free(completed.text); + try std.testing.expect(!completed.is_error); + try std.testing.expect(std.mem.indexOf(u8, completed.text, "child-ok") != null); + try std.testing.expectEqual(@as(u64, 1), runtime.recovery.stats().active_id); + try std.testing.expectEqual(@as(usize, 1), accepted.load(.acquire)); + try std.testing.expectEqual(@as(usize, 0), runtime.recovery.stats().active_refs); +} diff --git a/src/http_headers.zig b/src/http_headers.zig index 4646dea2..190a3ee1 100644 --- a/src/http_headers.zig +++ b/src/http_headers.zig @@ -38,6 +38,15 @@ pub fn sessionId(io: Io) []const u8 { return session_id_buf[0..session_id_len]; } +/// A clone-on-write session is a new durable conversation, not another name +/// for the parent's cache identity. Mint its UUID before the first child save. +pub fn renewSessionId(io: Io) []const u8 { + while (session_id_lock.cmpxchgWeak(false, true, .acquire, .monotonic) != null) std.atomic.spinLoopHint(); + session_id_len = 0; + session_id_lock.store(false, .release); + return sessionId(io); +} + /// Conversation affinity for one request. xAI routes `x-grok-conv-id` (Chat /// Completions) and `prompt_cache_key` (Responses) to the same server — cache /// entries are per-server, so a sticky id is how prefix hits stay reliable diff --git a/src/http_warm.zig b/src/http_warm.zig index ff5c88cf..ec2f0119 100644 --- a/src/http_warm.zig +++ b/src/http_warm.zig @@ -1,19 +1,51 @@ -//! Shared HTTP client CA-bundle warming. +//! Shared CA-bundle warming for HTTP and WSS. const std = @import("std"); const Io = std.Io; +/// Process-lifetime bundle. Always `page_allocator` so a test GPA cannot +/// free it while a later WSS dial still holds the pointer. +var process_ca: std.crypto.Certificate.Bundle = .empty; +var process_ca_rw: Io.RwLock = .init; +var process_ca_init: Io.Mutex = .init; +var process_ca_ready = std.atomic.Value(bool).init(false); + +/// Test seam: how many times the process bundle actually hit the disk. +pub var process_ca_rescans: u32 = 0; + +pub fn processCa() *std.crypto.Certificate.Bundle { + return &process_ca; +} + +pub fn processCaLock() *Io.RwLock { + return &process_ca_rw; +} + +/// Scan the host CA store once. Later WSS reconnects reuse the same bytes. +pub fn ensureProcessCa(io: Io) !void { + if (process_ca_ready.load(.acquire)) return; + process_ca_init.lockUncancelable(io); + defer process_ca_init.unlock(io); + if (process_ca_ready.load(.acquire)) return; + const now = Io.Clock.real.now(io); + process_ca.rescan(std.heap.page_allocator, io, now) catch return error.HandshakeFailed; + process_ca_rescans += 1; + process_ca_ready.store(true, .release); +} + /// Pre-load the shared HTTP client's CA bundle single-threaded so concurrent -/// agents never race Zig's lazy first-connect rescan. -pub fn prewarmCaBundle(client: *std.http.Client, gpa: std.mem.Allocator, io: Io) void { +/// agents never race Zig's lazy first-connect rescan. Also warms the process +/// bundle so the first WSS dial does not pay a second disk walk. +pub fn prewarmCaBundle(client: *std.http.Client, gpa: std.mem.Allocator, io: Io) !void { const now = Io.Clock.real.now(io); - client.ca_bundle.rescan(gpa, io, now) catch return; + try client.ca_bundle.rescan(gpa, io, now); client.now = now; + ensureProcessCa(io) catch return; } /// Warm off the launch critical path. Outbound users wait on /// `http.g_client_ready`, so prompt painting can overlap the scan safely. -pub fn prewarmCaBundleTask(client: *std.http.Client, gpa: std.mem.Allocator, io: Io, ready: *Io.Event) void { +pub fn prewarmCaBundleTask(client: *std.http.Client, gpa: std.mem.Allocator, io: Io, ready: *Io.Event, failed: *std.atomic.Value(bool)) void { defer ready.set(io); - prewarmCaBundle(client, gpa, io); + prewarmCaBundle(client, gpa, io) catch failed.store(true, .release); } diff --git a/src/input_util.zig b/src/input_util.zig index c71898df..d812912e 100644 --- a/src/input_util.zig +++ b/src/input_util.zig @@ -23,6 +23,7 @@ const runCapped = jobs.runCapped; const pricing = @import("pricing.zig"); const util = @import("util.zig"); +const readline_paste = @import("readline_paste.zig"); const main_mod = @import("main.zig"); const provider_mod = @import("provider.zig"); @@ -303,12 +304,12 @@ pub fn cleanDroppedPath(gpa: Allocator, home: []const u8, pasted: []const u8) ?[ // Redraw the whole input below a fixed prompt prefix, wrapping it // across rows. Spans listed in `marks` (paths from the @ picker or a -// file drop, plus "[Image]") render as an accent chip (reverse video + -// Codegraff emerald) so they keep reading as attached files, not typed words; a -// chip crossing a row break keeps its colour. `st` carries the row -// count + cursor row of the previous draw so this one can clear it -// with relative moves only — no DECSC anchor for a scroll to strand. -pub fn redraw(o: *Io.Writer, items: []const u8, c: usize, marks: []const []const u8, st: *LineRender, pcol: usize) void { +// file drop, plus "[Image]") and live semantic paste spans render as accent +// chips (reverse video + Codegraff emerald), so attachments do not look like +// typed words. A chip crossing a row break keeps its colour. `st` carries the +// row count + cursor row of the previous draw so this one can clear it with +// relative moves only — no DECSC anchor for a scroll to strand. +pub fn redraw(o: *Io.Writer, items: []const u8, c: usize, marks: []const []const u8, pastes: ?*const readline_paste.Store, st: *LineRender, pcol: usize) void { const cols = termCols(); const plen = if (pcol > 0) pcol - 1 else 0; // columns the prompt holds on row 0 @@ -357,18 +358,21 @@ pub fn redraw(o: *Io.Writer, items: []const u8, c: usize, marks: []const []const vcol = 0; } if (!mark_open) { // open a chip that starts here (longest wins) - var best: usize = 0; + var best_end: usize = i; for (marks) |m| { if (m.len == 0 or i + m.len > items.len) continue; - if (std.mem.eql(u8, items[i .. i + m.len], m) and m.len > best) best = m.len; + if (std.mem.eql(u8, items[i .. i + m.len], m)) best_end = @max(best_end, i + m.len); } - if (best > 0) { + if (pastes) |store| { + if (store.highlightEndAt(items, i)) |end| best_end = @max(best_end, end); + } + if (best_end > i) { if (shine_active) { o.writeAll("\x1b[0m") catch {}; shine_active = false; } o.writeAll(if (main_mod.use_color) "\x1b[7;38;2;5;150;105m" else "\x1b[7m") catch {}; - mark_end = i + best; + mark_end = best_end; mark_open = true; } } diff --git a/src/learn_store_types.zig b/src/learn_store_types.zig index 516cb8d3..75b2810e 100644 --- a/src/learn_store_types.zig +++ b/src/learn_store_types.zig @@ -10,7 +10,9 @@ pub const version_bytes = "1\n"; pub const max_config_bytes: usize = 1 << 20; pub const max_record_bytes: usize = 8 << 20; -pub const max_program_bytes: u64 = 128 << 20; +// The supported Linux debug build is itself a pinnable evaluator; keep this +// bounded while leaving headroom beyond its current ~128 MiB link image. +pub const max_program_bytes: u64 = 160 << 20; pub const max_suite_bytes: usize = 8 << 20; pub const max_pairs: usize = 4096; diff --git a/src/libgraff.zig b/src/libgraff.zig index f54d9d15..5108379b 100644 --- a/src/libgraff.zig +++ b/src/libgraff.zig @@ -76,7 +76,7 @@ export fn graff_acp_create(new_seed: u32) void { session_id = null; seed = if (new_seed == 0) 1 else new_seed; engine.cancel_flag.store(false, .release); - engine.implementation_version = "0.0.281-core"; + engine.implementation_version = "0.0.282-core"; } export fn graff_acp_feed(len: usize) i32 { diff --git a/src/main.zig b/src/main.zig index 158f7966..a7cbf573 100644 --- a/src/main.zig +++ b/src/main.zig @@ -4,9 +4,7 @@ const std = @import("std"); pub const panic = @import("tui").restore.Panic; // leave the alt screen BEFORE std prints a panic, or the restore sequence erases the trace (#535) const Io = std.Io; -const http_warm = @import("http_warm.zig"); -pub const prewarmCaBundle = http_warm.prewarmCaBundle; -const prewarmCaBundleTask = http_warm.prewarmCaBundleTask; +const http_client = @import("http_client.zig"); const Value = std.json.Value; const Allocator = std.mem.Allocator; const mcp = @import("mcp.zig"); @@ -292,16 +290,11 @@ pub fn main(init: std.process.Init) !void { const stale_saved_model = resolved_keys.stale_saved_model; const preferred_provider = resolved_keys.preferred_provider; const codex_account = resolved_keys.codex_account; - var client: std.http.Client = .{ .allocator = gpa, .io = io }; - defer client.deinit(); - var client_ready: Io.Event = .unset; - http.g_client_ready = &client_ready; - var client_warm_fut = io.async(prewarmCaBundleTask, .{ &client, gpa, io, &client_ready }); + var client_runtime: http_client.Runtime = undefined; + client_runtime.init(gpa, io); + defer client_runtime.deinit(startup_timing.shutdown_trace.at(io, "ca-warm-await")); + const client = &client_runtime.client; boot.mark(io, "CA warm scheduled"); - defer { - _ = client_warm_fut.await(startup_timing.shutdown_trace.at(io, "ca-warm-await")); - http.g_client_ready = null; - } var stdin_buf: [64 * 1024]u8 = undefined; var stdin_reader = Io.File.stdin().reader(io, &stdin_buf); const in = &stdin_reader.interface; @@ -309,7 +302,7 @@ pub fn main(init: std.process.Init) !void { var stdout_writer = Io.File.stdout().writer(io, &stdout_buf); const out = &stdout_writer.interface; g_out = out; - if (try session_start.runTitleCommand(io, gpa, arena, &client, default_provider, out, flags, &invocation_budget)) return; + if (try session_start.runTitleCommand(io, gpa, arena, client, default_provider, out, flags, &invocation_budget)) return; // Generate identity before opening either JSONL. The score channel and both // files share this run id; session_id is a separate runtime correlation id. session_start.initScoreRunId(io); @@ -359,7 +352,7 @@ pub fn main(init: std.process.Init) !void { } traj.node(.{ .kind = "session", .version = harness_version, .unix_ms = unixMs(io) }); - var telem = session_start.initTelemetry(io, gpa, &client, init.environ_map, flags, default_telemetry_endpoint); + var telem = session_start.initTelemetry(io, gpa, client, init.environ_map, flags, default_telemetry_endpoint); telemetry.g_telem = &telem; if (init.environ_map.get("GRAFF_FLEET")) |fv| { g_fleet = !(std.ascii.eqlIgnoreCase(fv, "off") or std.mem.eql(u8, fv, "0") or std.ascii.eqlIgnoreCase(fv, "false") or std.ascii.eqlIgnoreCase(fv, "no")); @@ -425,7 +418,7 @@ pub fn main(init: std.process.Init) !void { // Root Agent construction + post-construction config (session name, persisted thinking/goal/eval settings, session-start trace note) + the // backgrounded fleet-champion pull live in session_start.zig. `root`'s pointer fields (snapshots/client/tracer/approvals/registry) all reference // already-stable main()-owned storage passed in by address, so returning the constructed Agent by value here is safe. - var root = try session_run.buildRootAgent(gpa, arena, io, &client, default_provider, subagent_provider, init.environ_map, out, in, registry, &approvals, &tracer, sys_normal, &snaps, flags, telem.endpoint); + var root = try session_run.buildRootAgent(gpa, arena, io, client, default_provider, subagent_provider, init.environ_map, out, in, registry, &approvals, &tracer, sys_normal, &snaps, flags, telem.endpoint); root.run_budget = &invocation_budget; root.model_catalog = resolved_keys.model_catalog; root.stored_keys_loaded = resolved_keys.stored_keys_loaded; @@ -439,7 +432,7 @@ pub fn main(init: std.process.Init) !void { // JSONL and the privacy-projected upload are independent sinks; the Boot // owns both, wired and torn down in LIFO order (behavior_trace.zig). var behavior_buf: [8 * 1024]u8 = undefined; - var behavior_boot = behavior_trace.boot(io, gpa, &client, init.environ_map, telem.endpoint, telem.auth_key, telem.install_id, telem.client_name, harness_version, &behavior_buf); + var behavior_boot = behavior_trace.boot(io, gpa, client, init.environ_map, telem.endpoint, telem.auth_key, telem.install_id, telem.client_name, harness_version, &behavior_buf); behavior_boot.link(&tracer); // A dead local sink must not be silent: the collision that disabled local // capture in every session shipped invisibly because every failure path @@ -482,9 +475,8 @@ pub fn main(init: std.process.Init) !void { // Closing the learning loop: this session counts toward the next trial. defer session_run.startBackgroundLearning(gpa, arena, startup_timing.shutdown_trace.at(io, "background-learning"), init.environ_map, &invocation_budget, !flags.no_telemetry_flag); - // `graff` is the default session. TTY `graff repl` / `graff tui` open the Grok-style pager. - // `graff acp` (acp.zig) is the same idea over Zed's stdio Agent Client Protocol. Both self-contained — each exits after. - if (try session_run.runReplCommand(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), &client, in, out, arena, flags) or try @import("acp.zig").runAcpCommand(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), &client, in, out, arena, flags) or try @import("tui_launch.zig").maybeRun(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), &client, arena, flags, json_mode, g_cwd_display)) return; + // Pager frontends sync their engine conversation back before root finalization; ACP remains self-contained. + if (try session_run.runFrontendCommands(gpa, io, init.environ_map, &root, @import("bench_priors.zig").noteKeys(&keys), client, in, out, arena, flags, json_mode, g_cwd_display, startup_timing.shutdown_trace.at(io, "final-save"))) return; // One-shot print mode: run the single prompt to completion, print the final text to stdout, exit. if (flags.oneshot_prompt) |prompt_text| { try session_run.runOneshotPrompt(gpa, io, arena, &root, @import("bench_priors.zig").noteKeys(&keys), &tracer, out, prompt_text); // one-shot exits before loop_ctx below — capture keys for sub-first routing here too @@ -584,6 +576,10 @@ test { // pull in tests from imported modules (mcp.zig) _ = @import("mcp.zig"); _ = @import("mcp_rpc.zig"); _ = @import("main_test.zig"); + _ = @import("http_client.zig"); + _ = @import("http_client_tests.zig"); + _ = @import("http_client_integration_tests.zig"); + _ = @import("http_client_trajectory_tests.zig"); // A module whose tests must run needs an explicit reference here (a plain @import elsewhere compiles to nothing); scripts/eval-tier1.sh --only reach catches one. _ = @import("test_hooks.zig"); // unreached modules; their tests were silently skipped _ = @import("agent_overflow_tests.zig"); // #414: and, through it, agent_overflow.zig's table tests diff --git a/src/mcp_http.zig b/src/mcp_http.zig index dd3bf7ec..80489e7d 100644 --- a/src/mcp_http.zig +++ b/src/mcp_http.zig @@ -174,7 +174,6 @@ fn httpPostUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta, .redirect_behavior = .unhandled, .headers = .{ .content_type = .{ .override = "application/json" }, - .accept_encoding = .omit, .user_agent = .{ .override = "codegraff-mcp/1" }, }, .extra_headers = extra, @@ -224,28 +223,7 @@ fn httpPostUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta, } } - if (response.head.content_length == 0) return null; - const is_sse = if (response.head.content_type) |content_type| - std.ascii.startsWithIgnoreCase(content_type, "text/event-stream") - else - false; - var transfer_buf: [4096]u8 = undefined; - const reader = response.reader(&transfer_buf); - if (is_sse) return readSseResponse(http.client.allocator, reader, expected_id); - - const response_buf = try http.client.allocator.alloc(u8, max_http_response); - errdefer http.client.allocator.free(response_buf); - var fixed = Io.Writer.fixed(response_buf); - _ = reader.streamRemaining(&fixed) catch |err| switch (err) { - error.WriteFailed => return error.McpResponseTooLarge, - else => return err, - }; - const len = fixed.buffered().len; - if (len == 0) { - http.client.allocator.free(response_buf); - return null; - } - return try http.client.allocator.realloc(response_buf, len); + return readResponseBody(http.client.allocator, &response, expected_id); } const HttpPostDone = union(enum) { @@ -325,7 +303,6 @@ fn probeUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta) !Pr .redirect_behavior = .unhandled, .headers = .{ .content_type = .{ .override = "application/json" }, - .accept_encoding = .omit, .user_agent = .{ .override = "codegraff-mcp/1" }, }, .extra_headers = extra, @@ -355,17 +332,35 @@ fn probeUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta) !Pr if (req.connection) |connection| connection.closing = true; } - if (response.head.content_length == 0) return .{ .status = status, .body = null }; + return .{ .status = status, .body = try readResponseBody(http.client.allocator, &response, null) }; +} + +fn decompressWindow(gpa: Allocator, encoding: std.http.ContentEncoding) ![]u8 { + return switch (encoding) { + .identity => &.{}, + .zstd => gpa.alloc(u8, std.compress.zstd.default_window_len), + .deflate, .gzip => gpa.alloc(u8, std.compress.flate.max_window_len), + .compress => error.UnsupportedCompressionMethod, + }; +} + +/// Decode the HTTP body, honoring Content-Encoding (gzip/deflate/zstd). +/// Callers used to omit Accept-Encoding so catalogs crossed the wire raw. +fn readResponseBody(gpa: Allocator, response: *std.http.Client.Response, expected_id: ?i64) !?[]u8 { + if (response.head.content_length == 0) return null; const is_sse = if (response.head.content_type) |content_type| std.ascii.startsWithIgnoreCase(content_type, "text/event-stream") else false; + const window = try decompressWindow(gpa, response.head.content_encoding); + defer if (window.len > 0) gpa.free(window); var transfer_buf: [4096]u8 = undefined; - const reader = response.reader(&transfer_buf); - if (is_sse) return .{ .status = status, .body = try readSseResponse(http.client.allocator, reader, null) }; + var decompress: std.http.Decompress = undefined; + const reader = response.readerDecompressing(&transfer_buf, &decompress, window); + if (is_sse) return readSseResponse(gpa, reader, expected_id); - const response_buf = try http.client.allocator.alloc(u8, max_http_response); - errdefer http.client.allocator.free(response_buf); + const response_buf = try gpa.alloc(u8, max_http_response); + errdefer gpa.free(response_buf); var fixed = Io.Writer.fixed(response_buf); _ = reader.streamRemaining(&fixed) catch |err| switch (err) { error.WriteFailed => return error.McpResponseTooLarge, @@ -373,10 +368,10 @@ fn probeUnwatched(http: *HttpTransport, body: []const u8, meta: RequestMeta) !Pr }; const len = fixed.buffered().len; if (len == 0) { - http.client.allocator.free(response_buf); - return .{ .status = status, .body = null }; + gpa.free(response_buf); + return null; } - return .{ .status = status, .body = try http.client.allocator.realloc(response_buf, len) }; + return try gpa.realloc(response_buf, len); } const ProbeDone = union(enum) { diff --git a/src/mcp_lifecycle.zig b/src/mcp_lifecycle.zig index a7386758..abdf7992 100644 --- a/src/mcp_lifecycle.zig +++ b/src/mcp_lifecycle.zig @@ -6,7 +6,6 @@ //! surfaced rather than hidden behind fallback, matching rust-sdk Auto. const std = @import("std"); -const Io = std.Io; const Value = std.json.Value; const Allocator = std.mem.Allocator; @@ -59,27 +58,12 @@ const ProbeOut = struct { id: i64, }; -fn probeMethod( - io: Io, - gpa: Allocator, - url: []const u8, - headers: []const std.http.Header, - oauth_home: ?[]const u8, - method: []const u8, - id: i64, -) ProbeOut { - var arena_state = std.heap.ArenaAllocator.init(gpa); +fn probeMethod(http: *mcp_http.HttpTransport, method: []const u8, id: i64) ProbeOut { + var arena_state = std.heap.ArenaAllocator.init(http.client.allocator); defer arena_state.deinit(); - var transport: mcp_http.HttpTransport = .{ - .url = url, - .client = .{ .allocator = gpa, .io = io }, - .headers = headers, - .oauth_home = oauth_home, - }; - defer transport.client.deinit(); const body = mcp_protocol.buildRequest(arena_state.allocator(), id, method, "{}", true) catch return .{ .reply = .{ .status = 0, .body = null }, .id = id }; - const reply = mcp_http.probe(&transport, body, .{ + const reply = mcp_http.probe(http, body, .{ .protocol_version = modern_protocol, .method = method, .modern = true, @@ -126,7 +110,7 @@ fn connectHttpAttempt(server: *mcp_rpc.Server, a: Allocator, session_alloc: Allo const id_list = server.next_id; server.next_id += 1; - const list = probeMethod(io, gpa, http.url, http.headers, http.oauth_home, "tools/list", id_list); + const list = probeMethod(http, "tools/list", id_list); defer if (list.reply.body) |b| gpa.free(b); // Any modern request may be first. A real tools/list result is the catalog @@ -232,6 +216,14 @@ test "Auto: first launch tries modern tools/list before legacy fallback" { try std.testing.expect(list_pos < fallback_pos); } +test "Auto: modern probe reuses the persistent HTTP client" { + const src = @embedFile("mcp_lifecycle.zig"); + try std.testing.expect(std.mem.indexOf(u8, src, "fn probeMethod(http: *mcp_http.HttpTransport") != null); + try std.testing.expect(std.mem.indexOf(u8, src, "const list = probeMethod(http,") != null); + const throwaway = "var transport: " ++ "mcp_http.HttpTransport"; + try std.testing.expect(std.mem.indexOf(u8, src, throwaway) == null); +} + test { _ = @import("mcp_cache.zig"); } diff --git a/src/mcp_rpc.zig b/src/mcp_rpc.zig index ed5dc316..15a7d033 100644 --- a/src/mcp_rpc.zig +++ b/src/mcp_rpc.zig @@ -236,43 +236,21 @@ pub fn connectLegacy(server: *Server, a: Allocator, session_alloc: Allocator, bo return listed; } -const InitializedJob = struct { - url: []const u8, - headers: []const std.http.Header, - oauth_home: ?[]const u8, - protocol_version: []const u8, - gpa: Allocator, -}; - -fn httpInitializedTask(io: Io, job: InitializedJob) void { - var transport: mcp_http.HttpTransport = .{ - .url = job.url, - .client = .{ .allocator = job.gpa, .io = io }, - .headers = job.headers, - .oauth_home = job.oauth_home, - }; - defer transport.client.deinit(); +fn httpInitializedTask(http: *mcp_http.HttpTransport, protocol_version: []const u8) void { const body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\",\"params\":{}}"; - if (mcp_http.post(&transport, body, .{ - .protocol_version = job.protocol_version, + if (mcp_http.post(http, body, .{ + .protocol_version = protocol_version, .method = "notifications/initialized", .modern = false, }, null)) |maybe| { - if (maybe) |b| job.gpa.free(b); + if (maybe) |b| http.client.allocator.free(b); } else |_| {} } fn kickHttpInitialized(server: *Server) void { const http = &server.transport.http; - const job = InitializedJob{ - .url = http.url, - .headers = http.headers, - .oauth_home = http.oauth_home, - .protocol_version = server.protocol_version, - .gpa = http.client.allocator, - }; - server.pending_initialized = http.client.io.concurrent(httpInitializedTask, .{ http.client.io, job }) catch - http.client.io.async(httpInitializedTask, .{ http.client.io, job }); + server.pending_initialized = http.client.io.concurrent(httpInitializedTask, .{ http, server.protocol_version }) catch + http.client.io.async(httpInitializedTask, .{ http, server.protocol_version }); } pub fn finishInitialized(server: *Server) void { diff --git a/src/named_work.zig b/src/named_work.zig index 762ddaa7..e67ad546 100644 --- a/src/named_work.zig +++ b/src/named_work.zig @@ -21,7 +21,7 @@ pub const nudge_text = /// after the first step, so `handle` must not depend on walking messages. var remembered_task: []const u8 = ""; -const source_needles = [_][]const u8{ ".py", ".zig", ".js", ".ts", "SPEC.md" }; +const source_needles = [_][]const u8{ ".py", ".zig", ".jsx", ".js", ".tsx", ".ts", "SPEC.md" }; pub fn remember(text: []const u8) void { remembered_task = text; @@ -55,8 +55,13 @@ pub fn beginTurn(self: *Agent) void { /// True when `text` names a source path (not a greeting.txt-style data file). pub fn hasNamedSource(text: []const u8) bool { - for (source_needles) |n| { - if (std.mem.indexOf(u8, text, n) != null) return true; + for (source_needles) |needle| { + var from: usize = 0; + while (std.mem.indexOfPos(u8, text, from, needle)) |at| { + const end = at + needle.len; + if (end == text.len or (!std.ascii.isAlphanumeric(text[end]) and text[end] != '_')) return true; + from = end; + } } return false; } @@ -135,10 +140,12 @@ pub fn handle(self: *Agent, _: []const u8) !bool { return true; } -test "hasNamedSource sees SPEC and .py, ignores greeting.txt and pong" { +test "hasNamedSource sees source suffixes without confusing JSON for JavaScript" { try std.testing.expect(hasNamedSource("Read SPEC.md and affinity.py")); try std.testing.expect(hasNamedSource("Fix fib.py")); - try std.testing.expect(hasNamedSource("edit stall_notice.py")); + try std.testing.expect(hasNamedSource("edit view.tsx and helper.jsx")); + try std.testing.expect(!hasNamedSource("resume .graff/sessions/task.session.json")); + try std.testing.expect(!hasNamedSource("read task.transcript.jsonl")); try std.testing.expect(!hasNamedSource("Reply with exactly: pong")); try std.testing.expect(!hasNamedSource("Create hello.txt then rename it")); } diff --git a/src/net_efficiency_test.zig b/src/net_efficiency_test.zig new file mode 100644 index 00000000..c7f401a9 --- /dev/null +++ b/src/net_efficiency_test.zig @@ -0,0 +1,244 @@ +//! Measured hot-path checks for reuse on the networks graff already speaks: +//! process-warmed WSS CA, MCP Streamable HTTP keep-alive, and the source +//! guards that keep those clients from becoming throwaways again. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; + +const http_warm = @import("http_warm.zig"); +const mcp_http = @import("mcp_http.zig"); +const mcp_lifecycle = @import("mcp_lifecycle.zig"); +const mcp_protocol = @import("mcp_protocol.zig"); +const mcp_rpc = @import("mcp_rpc.zig"); + +test "WSS CA bundle is scanned from disk at most once per process" { + const io = std.testing.io; + const before = http_warm.process_ca_rescans; + try http_warm.ensureProcessCa(io); + const mid = http_warm.process_ca_rescans; + try http_warm.ensureProcessCa(io); + const after = http_warm.process_ca_rescans; + try std.testing.expect(mid == before or mid == before + 1); + try std.testing.expectEqual(mid, after); + try std.testing.expect(http_warm.processCa().map.count() > 0); +} + +test "MCP initialized notify reuses the persistent HTTP client" { + const src = @embedFile("mcp_rpc.zig"); + try std.testing.expect(std.mem.indexOf(u8, src, "fn httpInitializedTask(http: *mcp_http.HttpTransport") != null); + try std.testing.expect(std.mem.indexOf(u8, src, "http.client.io.concurrent(httpInitializedTask, .{ http,") != null); + const throwaway = "var transport: " ++ "mcp_http.HttpTransport"; + try std.testing.expect(std.mem.indexOf(u8, src, throwaway) == null); +} + +test "WS→SSE fallback latches the prewarmed Agent client, not a fresh pool" { + const src = @embedFile("agent_ws.zig"); + const latch = std.mem.indexOf(u8, src, "return self.postStream(body);").?; + try std.testing.expect(std.mem.indexOf(u8, src, "postStreamFresh") == null); + try std.testing.expect(std.mem.indexOf(u8, src, "using persistent prewarmed SSE") != null); + _ = latch; +} + +const ListSrv = struct { + accepts: *std.atomic.Value(u8), + posts: *std.atomic.Value(u8), + done: *std.atomic.Value(bool), + + fn run(self: *ListSrv, io: Io, listener: *std.Io.net.Server) void { + while (!self.done.load(.acquire)) { + const stream = listener.accept(io) catch { + if (self.done.load(.acquire)) return; + continue; + }; + _ = self.accepts.fetchAdd(1, .monotonic); + defer stream.close(io); + self.serveConn(io, stream) catch {}; + } + } + + fn serveConn(self: *ListSrv, io: Io, stream: std.Io.net.Stream) !void { + var rbuf: [4096]u8 = undefined; + var wbuf: [4096]u8 = undefined; + var rd = std.Io.net.Stream.Reader.init(stream, io, &rbuf); + var wr = std.Io.net.Stream.Writer.init(stream, io, &wbuf); + const r = &rd.interface; + const w = &wr.interface; + while (self.posts.load(.acquire) < 2) { + var content_len: usize = 0; + while (true) { + const line = (r.takeDelimiter('\n') catch return) orelse return; + if (line.len == 0 or (line.len == 1 and line[0] == '\r')) break; + if (std.ascii.startsWithIgnoreCase(line, "content-length:")) { + const raw = if (line[line.len - 1] == '\r') line[0 .. line.len - 1] else line; + const v = std.mem.trim(u8, raw["content-length:".len..], " \t"); + content_len = std.fmt.parseInt(usize, v, 10) catch 0; + } + } + if (content_len > 0) _ = try r.take(content_len); + const n = self.posts.fetchAdd(1, .monotonic) + 1; + const body = if (n == 1) + \\{"jsonrpc":"2.0","id":1,"result":{"tools":[],"supportedVersions":["2026-07-28"]}} + else + \\{"jsonrpc":"2.0","id":2,"result":{"tools":[]}} + ; + try w.print( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n{s}", + .{ body.len, body }, + ); + try w.flush(); + } + } +}; + +test "MCP modern connect + next list reuse one TCP connection" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + + var addr = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var listener = try std.Io.net.IpAddress.listen(&addr, io, .{}); + defer listener.deinit(io); + + var accepts: std.atomic.Value(u8) = .init(0); + var posts: std.atomic.Value(u8) = .init(0); + var done: std.atomic.Value(bool) = .init(false); + var srv: ListSrv = .{ .accepts = &accepts, .posts = &posts, .done = &done }; + var fut = io.async(ListSrv.run, .{ &srv, io, &listener }); + defer fut.await(io); + defer done.store(true, .release); + defer if (std.Io.net.IpAddress.connect(&listener.socket.address, io, .{ .mode = .stream })) |s| s.close(io) else |_| {}; + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/mcp", .{listener.socket.address.getPort()}); + + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var server: mcp_rpc.Server = .{ + .name = "loop", + .transport = .{ .http = .{ + .url = url, + .client = .{ .allocator = gpa, .io = io }, + } }, + }; + defer server.transport.http.client.deinit(); + + const first = try mcp_lifecycle.connectHttp(&server, arena, arena, .unknown); + try std.testing.expect(first.object.get("result") != null); + const second = try mcp_rpc.request(&server, arena, "{}", "tools/list", null); + try std.testing.expect(second.object.get("result") != null); + + try std.testing.expectEqual(@as(u8, 1), accepts.load(.monotonic)); + try std.testing.expectEqual(@as(u8, 2), posts.load(.monotonic)); +} + +test "MCP HTTP advertises gzip and decompresses a smaller wire body" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + + var plain_w: Io.Writer.Allocating = .init(gpa); + defer plain_w.deinit(); + try plain_w.writer.writeAll("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":["); + for (0..40) |i| { + if (i != 0) try plain_w.writer.writeByte(','); + try plain_w.writer.print( + "{{\"name\":\"tool_{d}\",\"description\":\"search the workspace and return matching files\",\"inputSchema\":{{\"type\":\"object\",\"properties\":{{\"q\":{{\"type\":\"string\"}}}}}}}}", + .{i}, + ); + } + try plain_w.writer.writeAll("],\"supportedVersions\":[\"2026-07-28\"]}}"); + const plain = plain_w.writer.buffered(); + + const gz = try gzipAlloc(gpa, plain); + defer gpa.free(gz); + try std.testing.expect(gz.len < plain.len); + + var saw_accept_gzip = std.atomic.Value(bool).init(false); + var wire_len = std.atomic.Value(usize).init(0); + + var addr = try std.Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var listener = try std.Io.net.IpAddress.listen(&addr, io, .{}); + defer listener.deinit(io); + const Srv = struct { + fn run( + io_: Io, + listener_: *std.Io.net.Server, + gz_: []const u8, + saw: *std.atomic.Value(bool), + wire: *std.atomic.Value(usize), + ) void { + const stream = listener_.accept(io_) catch return; + defer stream.close(io_); + var rbuf: [4096]u8 = undefined; + var rd = std.Io.net.Stream.Reader.init(stream, io_, &rbuf); + const r = &rd.interface; + var content_len: usize = 0; + var accept_enc: bool = false; + while (true) { + const line = (r.takeDelimiter('\n') catch return) orelse return; + if (line.len == 0 or (line.len == 1 and line[0] == '\r')) break; + if (std.ascii.startsWithIgnoreCase(line, "accept-encoding:") and + std.mem.indexOf(u8, line, "gzip") != null) accept_enc = true; + if (std.ascii.startsWithIgnoreCase(line, "content-length:")) { + const raw = if (line[line.len - 1] == '\r') line[0 .. line.len - 1] else line; + content_len = std.fmt.parseInt(usize, std.mem.trim(u8, raw["content-length:".len..], " \t"), 10) catch 0; + } + } + if (content_len > 0) _ = r.take(content_len) catch {}; + saw.store(accept_enc, .release); + wire.store(gz_.len, .release); + var wbuf: [1024]u8 = undefined; + var wr = std.Io.net.Stream.Writer.init(stream, io_, &wbuf); + wr.interface.print( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Encoding: gzip\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{gz_.len}, + ) catch return; + wr.interface.writeAll(gz_) catch return; + wr.interface.flush() catch {}; + } + }; + var fut = io.async(Srv.run, .{ io, &listener, gz, &saw_accept_gzip, &wire_len }); + defer fut.await(io); + + var url_buf: [64]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/mcp", .{listener.socket.address.getPort()}); + var http: mcp_http.HttpTransport = .{ + .url = url, + .client = .{ .allocator = gpa, .io = io }, + }; + defer http.client.deinit(); + const body = (try mcp_http.post(&http, "{}", .{ + .protocol_version = mcp_protocol.modern_protocol, + .method = "tools/list", + .modern = true, + }, 1)) orelse return error.TestUnexpectedResult; + defer gpa.free(body); + + try std.testing.expect(saw_accept_gzip.load(.acquire)); + try std.testing.expectEqual(gz.len, wire_len.load(.acquire)); + try std.testing.expect(wire_len.load(.acquire) < plain.len); + try std.testing.expectEqualStrings(plain, body); +} + +test "MCP HTTP no longer omits Accept-Encoding" { + const src = @embedFile("mcp_http.zig"); + const omit = "accept_encoding = " ++ ".omit"; + try std.testing.expect(std.mem.indexOf(u8, src, omit) == null); + try std.testing.expect(std.mem.indexOf(u8, src, "readerDecompressing") != null); +} + +fn gzipAlloc(gpa: std.mem.Allocator, plain: []const u8) ![]u8 { + var out_buf: [4096]u8 = undefined; + var out: Io.Writer = .fixed(&out_buf); + const window = try gpa.alloc(u8, std.compress.flate.max_window_len); + defer gpa.free(window); + const c = try gpa.create(std.compress.flate.Compress); + defer gpa.destroy(c); + c.* = try std.compress.flate.Compress.init(&out, window, .gzip, .default); + try c.writer.writeAll(plain); + try c.finish(); + return gpa.dupe(u8, out.buffered()); +} diff --git a/src/no_local_tools.zig b/src/no_local_tools.zig index 071ca8b0..1fcd3dd1 100644 --- a/src/no_local_tools.zig +++ b/src/no_local_tools.zig @@ -125,7 +125,7 @@ pub const lean_subagent_desc = "Spawn a sidecar for a self-contained task. Child /// One-shot tool prose (ADR 0046). Same names and JSON schemas; shorter /// descriptions so flash models start and finish instead of rereading /// essays. Do not drop to four tools (ADR 0024). -pub const lean_bash_desc = "Run /bin/sh -c in cwd. Returns stdout, stderr, exit. Foreground still running after 120s (or timeout ms) backgrounds — bash_output(wait_ms>0) waits; do not poll."; +pub const lean_bash_desc = "Run /bin/sh -c in cwd. Returns stdout, stderr, exit. Foreground still running after 15s (or timeout ms) backgrounds — bash_output(wait_ms>0) waits; do not poll."; pub const lean_read_file_desc = "Read a UTF-8 file. Call before editing. contains=exact-key numbered lines; start_line/end_line for a window."; pub const lean_edit_file_desc = "Replace exact text. Prefer one batched edits[] over many calls. Prefer over write_file for an existing file."; pub const lean_codedb_desc = "Indexed nav: context · around · callpath A B · list_dir · status. Prefer over bash grep/find/ls."; diff --git a/src/prompt_snapshot_tests.zig b/src/prompt_snapshot_tests.zig index 8cabcd94..cdd9ba61 100644 --- a/src/prompt_snapshot_tests.zig +++ b/src/prompt_snapshot_tests.zig @@ -572,6 +572,7 @@ test "lean drops the todo/constraint capabilities from the prompt, never the loc const a = a_state.allocator(); const lean_prompt = try prompts.composeBase(a, lean); try std.testing.expect(lean_prompt.len < prompts.main_system_prompt.len); + try std.testing.expect(std.mem.indexOf(u8, lean_prompt, "described in prose is not done") != null); } test "unattended one-shots are told the REAL approval map up front; attended sessions hear nothing" { diff --git a/src/prompt_text.zig b/src/prompt_text.zig index 543ec15f..1903da88 100644 --- a/src/prompt_text.zig +++ b/src/prompt_text.zig @@ -279,6 +279,7 @@ pub const lean_parallel_note = pub const lean_intro_note = \\You are a coding agent. Use the cataloged tools; never invent one. + \\A file change described in prose is not done — call edit_file or write_file. \\Independent reads belong in ONE response, not one per turn. \\A passing test and attempt_completion belong in ONE response. ; diff --git a/src/provider_codegraff_tests.zig b/src/provider_codegraff_tests.zig index 103fb16a..f26a0f2f 100644 --- a/src/provider_codegraff_tests.zig +++ b/src/provider_codegraff_tests.zig @@ -127,6 +127,7 @@ test "Codegraff Gemini sends low for default effort; /effort high still sends" { const ds = try deepseek.buildBody(null, false, true, true); defer std.testing.allocator.free(ds); try std.testing.expect(std.mem.indexOf(u8, ds, "\"reasoning_effort\":\"medium\"") != null); + try std.testing.expect(std.mem.indexOf(u8, ds, "\"thinking\":{\"type\":\"enabled\"}") != null); } test "Codegraff glm-5.3-flash sends low for default effort; /effort high still sends" { @@ -164,3 +165,48 @@ test "Codegraff glm-5.3-flash sends low for default effort; /effort high still s defer std.testing.allocator.free(high); try std.testing.expect(std.mem.indexOf(u8, high, "\"reasoning_effort\":\"high\"") != null); } + +test "Codegraff DeepSeek flash disables thinking at default low; /effort high still thinks" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var messages = std.json.Array.init(arena); + try messages.append(.{ .object = blk: { + var obj: std.json.ObjectMap = .empty; + try obj.put(arena, "role", .{ .string = "user" }); + try obj.put(arena, "content", .{ .string = "hello" }); + break :blk obj; + } }); + var agent: Agent = .{ + .gpa = std.testing.allocator, + .arena = arena, + .io = std.testing.io, + .client = undefined, + .provider = build("deepseek-v4-flash"), + .messages = messages, + .sub = false, + .label = "main", + .out = null, + .sys_normal = "system", + }; + const low = try agent.buildBody(null, false, true, true); + defer std.testing.allocator.free(low); + try std.testing.expect(std.mem.indexOf(u8, low, "\"thinking\":{\"type\":\"disabled\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, low, "\"reasoning_effort\":\"low\"") != null); + try std.testing.expect(std.mem.indexOf(u8, low, "\"thinking\":{\"type\":\"enabled\"}") == null); + + agent.reasoning = .high; + const high = try agent.buildBody(null, false, true, true); + defer std.testing.allocator.free(high); + try std.testing.expect(std.mem.indexOf(u8, high, "\"thinking\":{\"type\":\"enabled\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, high, "\"reasoning_effort\":\"high\"") != null); + try std.testing.expect(std.mem.indexOf(u8, high, "\"thinking\":{\"type\":\"disabled\"}") == null); + + var glm: Agent = agent; + glm.provider = build("glm-5.3-flash"); + glm.reasoning = .medium; + const glm_body = try glm.buildBody(null, false, true, true); + defer std.testing.allocator.free(glm_body); + try std.testing.expect(std.mem.indexOf(u8, glm_body, "\"thinking\":{\"type\":\"disabled\"}") == null); + try std.testing.expect(std.mem.indexOf(u8, glm_body, "\"reasoning_effort\":\"low\"") != null); +} diff --git a/src/readline.zig b/src/readline.zig index 838f6887..37019316 100644 --- a/src/readline.zig +++ b/src/readline.zig @@ -40,13 +40,9 @@ const collectRepoFiles = input_util.collectRepoFiles; const isImagePath = input_util.isImagePath; const redraw = input_util.redraw; const editByte = input_util.editByte; // #396: job-control-aware continuation reads -const setLine = input_util.setLine; const delRange = input_util.delRange; -const prevWord = input_util.prevWord; -const nextWord = input_util.nextWord; const addMark = input_util.addMark; const insertImageChip = input_util.insertImageChip; -const markImageChips = input_util.markImageChips; const util = @import("util.zig"); const main_mod = @import("main.zig"); @@ -57,6 +53,8 @@ const saveSession = session.saveSession; const shutdown_trace = @import("shutdown_trace.zig"); // #364: the quit path's first phase stamp const rl_history = @import("readline_history.zig"); const HistoryNav = rl_history.HistoryNav; +const PasteStore = @import("readline_paste.zig").Store; +const replayStep = @import("readline_replay.zig").apply; /// Read one input line with a tiny raw-mode editor: ↑/↓ walk history, /// Tab completes/cycles (models, providers, slash commands), backspace edits, @@ -156,18 +154,10 @@ pub fn readLine( var comp_idx: usize = 0; var comp_active = false; - // Bracketed-paste collapse: a multi-line paste becomes a "[Pasted text #N - // +L lines]" placeholder in the buffer; on submit each placeholder is - // expanded back to its full text. - const Paste = struct { ph: []const u8, body: []const u8 }; - var pastes: std.ArrayList(Paste) = .empty; - defer { - for (pastes.items) |p| { - gpa.free(p.ph); - gpa.free(p.body); - } - pastes.deinit(gpa); - } + // Long pastes are semantic spans: their labels render as attachment chips, + // move atomically, and cannot be recreated by typing the same display text. + var pastes: PasteStore = .{}; + defer pastes.deinit(gpa); // File paths inserted by the @ picker or a drag-and-drop (plus the // "[Image]" attachment marker): redraw renders these spans highlighted. @@ -192,7 +182,7 @@ pub fn readLine( while (main_mod.use_color and util.indexOfIgnoreCase(buf.items, "ultracode") != null) { if (inputPendingTimed(140)) break; // keystroke ready — read it below input_util.g_shine_phase +%= 1; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } break :blk switch (tty.promptByte(in)) { .byte => |b| b, @@ -221,10 +211,12 @@ pub fn readLine( } else if (comp_items.items.len > 0) { comp_idx = (comp_idx + 1) % comp_items.items.len; } else continue; + const old_len = buf.items.len; buf.shrinkRetainingCapacity(comp_base); buf.appendSlice(gpa, comp_items.items[comp_idx]) catch {}; + pastes.edited(gpa, comp_base, old_len, buf.items.len - comp_base); cur = buf.items.len; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, '\r', '\n' => { // The cursor may be mid-block; step past the last input row so @@ -235,50 +227,45 @@ pub fn readLine( if (rstate.rows - 1 > rstate.crow) out.print("\x1b[{d}B", .{rstate.rows - 1 - rstate.crow}) catch {}; out.writeAll("\r\n\r\n") catch {}; out.flush() catch {}; - // Expand any pasted placeholders back to their full text. - for (pastes.items) |p| { - if (std.mem.indexOf(u8, buf.items, p.ph) == null) continue; - const sz = std.mem.replacementSize(u8, buf.items, p.ph, p.body); - const tmp = gpa.alloc(u8, sz) catch break; - _ = std.mem.replace(u8, buf.items, p.ph, p.body, tmp); - buf.clearRetainingCapacity(); - buf.appendSlice(gpa, tmp) catch {}; - gpa.free(tmp); - } + // Expand live semantic paste spans; typed lookalikes stay text. + try pastes.expand(gpa, buf); break; }, 0x01 => { // Ctrl-A → start of line cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x05 => { // Ctrl-E → end of line cur = buf.items.len; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x02 => if (cur > 0) { // Ctrl-B → left - cur -= 1; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = pastes.left(cur); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x06 => if (cur < buf.items.len) { // Ctrl-F → right - cur += 1; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = pastes.right(cur, buf.items.len); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x17, 0x1f => { // Ctrl-W / Ctrl-_ → delete previous word - const s = prevWord(buf.items, cur); + const s = pastes.prevWord(buf.items, cur); if (s < cur) { + pastes.edited(gpa, s, cur, 0); delRange(buf, s, cur); cur = s; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } }, 0x15 => if (cur > 0) { // Ctrl-U → delete to start of line + pastes.edited(gpa, 0, cur, 0); delRange(buf, 0, cur); cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x0b => if (cur < buf.items.len) { // Ctrl-K → delete to end of line + pastes.edited(gpa, cur, buf.items.len, 0); buf.shrinkRetainingCapacity(cur); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x16 => { // Ctrl-V: attach a clipboard image (macOS) at the cursor var mbuf: [224]u8 = undefined; @@ -290,8 +277,10 @@ pub fn readLine( vision.tracePasteResult(root, grab.flavor, staged); // #350: every paste leaves a receipt if (staged.isOk()) { @import("vision_queue.zig").markLastComposer(root); + const at = cur; insertImageChip(gpa, buf, &cur, &marks, root.pending_image_len); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + if (cur > at) pastes.edited(gpa, at, at, cur - at); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } else { // No `.no_vision` arm here: clipboardPasteSource // already answers .no_vision above, so on a @@ -310,13 +299,15 @@ pub fn readLine( out.print("\r\n{s}· {s}{s}", .{ style.dim, m, style.reset }) catch {}; root.prompt() catch {}; rstate = .{}; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } }, - 0x7f, 0x08 => if (cur > 0) { // backspace → delete char before cursor - delRange(buf, cur - 1, cur); - cur -= 1; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + 0x7f, 0x08 => if (cur > 0) { // backspace → delete previous atom + const start = pastes.left(cur); + pastes.edited(gpa, start, cur, 0); + delRange(buf, start, cur); + cur = start; + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x03 => { // Ctrl-C: clear a non-empty line; on an empty line, quit if (buf.items.len == 0) { @@ -325,8 +316,9 @@ pub fn readLine( return null; } buf.clearRetainingCapacity(); + pastes.clear(gpa); cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 0x1a => { // Ctrl-Z: save the session and quit (like a safe Ctrl-D) out.writeAll("^Z — saving & quit\n") catch {}; @@ -341,8 +333,10 @@ pub fn readLine( return null; } if (cur < buf.items.len) { - delRange(buf, cur, cur + 1); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + const end = pastes.right(cur, buf.items.len); + pastes.edited(gpa, cur, end, 0); + delRange(buf, cur, end); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } }, 0x1b => { // escape sequence: arrows, Alt/Option chords, CSI @@ -351,35 +345,38 @@ pub fn readLine( // the next keypress. if (tty.pendingBytes() == 0 and in.buffered().len == 0 and !inputPending()) { buf.clearRetainingCapacity(); + pastes.clear(gpa); cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); continue; } const b1 = editByte(in) orelse break; // #396: guarded if (b1 == 0x7f or b1 == 0x08) { // Option/Alt+Delete → delete previous word - const s = prevWord(buf.items, cur); + const s = pastes.prevWord(buf.items, cur); if (s < cur) { + pastes.edited(gpa, s, cur, 0); delRange(buf, s, cur); cur = s; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } continue; } if (b1 == 'b') { // Alt-b → word left - cur = prevWord(buf.items, cur); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = pastes.prevWord(buf.items, cur); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); continue; } if (b1 == 'f') { // Alt-f → word right - cur = nextWord(buf.items, cur); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = pastes.nextWord(buf.items, cur); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); continue; } if (b1 == 'd') { // Alt-d → delete next word - const e = nextWord(buf.items, cur); + const e = pastes.nextWord(buf.items, cur); if (e > cur) { + pastes.edited(gpa, cur, e, 0); delRange(buf, cur, e); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } continue; } @@ -403,28 +400,28 @@ pub fn readLine( const word_mod = std.mem.indexOfScalar(u8, ps, ';') != null; // 1;3 (alt) / 1;5 (ctrl) switch (final) { 'A' => if (nav.up(gpa, history.items, rl_history.g_history_images.slice(), buf.items, root.pending_image)) |step| { // up → history back; snapshots draft (#101) - replayStep(root, gpa, buf, &cur, &marks, step); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + replayStep(root, gpa, buf, &cur, &marks, &pastes, step); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'B' => if (nav.down(history.items, rl_history.g_history_images.slice())) |step| { // down → history forward; restores draft past newest (#101) - replayStep(root, gpa, buf, &cur, &marks, step); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + replayStep(root, gpa, buf, &cur, &marks, &pastes, step); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'C' => { // right (word-right with a modifier) - cur = if (word_mod) nextWord(buf.items, cur) else @min(cur + 1, buf.items.len); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = if (word_mod) pastes.nextWord(buf.items, cur) else pastes.right(cur, buf.items.len); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'D' => { // left (word-left with a modifier) - cur = if (word_mod) prevWord(buf.items, cur) else (if (cur > 0) cur - 1 else 0); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + cur = if (word_mod) pastes.prevWord(buf.items, cur) else pastes.left(cur); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'H' => { cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'F' => { cur = buf.items.len; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, 'R' => { // late DSR cursor-position reply (slow or // multiplexed terminal missed the 20ms startup @@ -442,7 +439,7 @@ pub fn readLine( } else prompt_col = col; } } - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, '~' => { if (std.mem.eql(u8, ps, "200")) { // bracketed paste start @@ -487,11 +484,18 @@ pub fn readLine( } if (staged) { @import("vision_queue.zig").markLastComposer(root); + const at = cur; insertImageChip(gpa, buf, &cur, &marks, root.pending_image_len); + if (cur > at) pastes.edited(gpa, at, at, cur - at); } else { + const at = cur; + const old_len = buf.items.len; buf.insertSlice(gpa, cur, dropped.?) catch {}; - cur += dropped.?.len; - addMark(gpa, &marks, dropped.?); + if (buf.items.len == old_len + dropped.?.len) { + pastes.edited(gpa, at, at, dropped.?.len); + cur += dropped.?.len; + addMark(gpa, &marks, dropped.?); + } } if (dmsg) |m| { // feedback below the input, then redraw fresh (below) if (rstate.rows - 1 > rstate.crow) out.print("\x1b[{d}B", .{rstate.rows - 1 - rstate.crow}) catch {}; @@ -500,29 +504,30 @@ pub fn readLine( rstate = .{}; } } else if (lines == 1 and pasted.len <= 80) { + const at = cur; + const old_len = buf.items.len; buf.insertSlice(gpa, cur, pasted) catch {}; // short single-line paste: inline - cur += pasted.len; - } else { // multi-line/long: collapse to a placeholder, expand on submit - const ph = std.fmt.allocPrint(gpa, "[Pasted text #{d} +{d} lines]", .{ pastes.items.len + 1, lines }) catch ""; - const body = gpa.dupe(u8, pasted) catch ""; - if (ph.len > 0 and body.len > 0) { - pastes.append(gpa, .{ .ph = ph, .body = body }) catch {}; - buf.insertSlice(gpa, cur, ph) catch {}; - cur += ph.len; + if (buf.items.len == old_len + pasted.len) { + pastes.edited(gpa, at, at, pasted.len); + cur += pasted.len; } + } else { // multi-line/long: semantic chip, expanded on submit + pastes.insert(gpa, buf, &cur, pasted, lines) catch {}; } - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } else if (std.mem.eql(u8, ps, "3")) { // forward delete if (cur < buf.items.len) { - delRange(buf, cur, cur + 1); - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + const end = pastes.right(cur, buf.items.len); + pastes.edited(gpa, cur, end, 0); + delRange(buf, cur, end); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } } else if (std.mem.eql(u8, ps, "1") or std.mem.eql(u8, ps, "7")) { cur = 0; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } else if (std.mem.eql(u8, ps, "4") or std.mem.eql(u8, ps, "8")) { cur = buf.items.len; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); } }, else => {}, @@ -549,17 +554,27 @@ pub fn readLine( // screen and cursor on exit, so the input block and // rstate still match — just redraw over them below. if (picked) |idx| { + const at = cur; + const old_len = buf.items.len; buf.insertSlice(gpa, cur, files.items[idx]) catch {}; - cur += files.items[idx].len; - addMark(gpa, &marks, files.items[idx]); + if (buf.items.len == old_len + files.items[idx].len) { + pastes.edited(gpa, at, at, files.items[idx].len); + cur += files.items[idx].len; + addMark(gpa, &marks, files.items[idx]); + } } - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); continue; } } + const at = cur; + const old_len = buf.items.len; buf.insert(gpa, cur, c) catch {}; - cur += 1; - redraw(out, buf.items, cur, marks.items, &rstate, prompt_col); + if (buf.items.len == old_len + 1) { + pastes.edited(gpa, at, at, 1); + cur += 1; + } + redraw(out, buf.items, cur, marks.items, &pastes, &rstate, prompt_col); }, } } @@ -578,20 +593,3 @@ pub fn readLine( } return buf.items; } - -/// Apply one history step to the editor: text into the buffer, and the entry's -/// attachment back onto the agent so resending sends the image and not just the -/// literal "[Image]" marker (#108). A text-only entry clears whatever was staged. -fn replayStep( - root: *Agent, - gpa: Allocator, - buf: *std.ArrayList(u8), - cur: *usize, - marks: *std.ArrayList([]const u8), - step: rl_history.Step, -) void { - setLine(gpa, buf, step.text); - cur.* = buf.items.len; - root.pending_image = step.image; - if (step.image != null) markImageChips(gpa, marks, buf.items); -} diff --git a/src/readline_paste.zig b/src/readline_paste.zig new file mode 100644 index 00000000..9206d512 --- /dev/null +++ b/src/readline_paste.zig @@ -0,0 +1,338 @@ +//! Semantic long-paste spans for the raw readline composer (#673). +//! +//! The visible `[Pasted text …]` label is presentation, not identity. Each live +//! span owns its hidden body and tracks its byte range as surrounding text is +//! edited. An edit touching the range detaches it, so retyping the same label +//! cannot resurrect a removed paste. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const Entry = struct { + start: usize, + end: usize, + label: []u8, + body: []u8, +}; + +pub const Store = struct { + entries: std.ArrayList(Entry) = .empty, + next_id: usize = 1, + + pub fn deinit(self: *Store, gpa: Allocator) void { + self.clear(gpa); + self.entries.deinit(gpa); + } + + pub fn clear(self: *Store, gpa: Allocator) void { + for (self.entries.items) |entry| freeEntry(gpa, entry); + self.entries.clearRetainingCapacity(); + } + + pub fn count(self: *const Store) usize { + return self.entries.items.len; + } + + /// Insert one collapsed long paste at the cursor. Existing spans move with + /// the surrounding text; the new span is inserted in positional order. + pub fn insert( + self: *Store, + gpa: Allocator, + buf: *std.ArrayList(u8), + cursor: *usize, + body: []const u8, + lines: usize, + ) !void { + const label = try std.fmt.allocPrint(gpa, "[Pasted text #{d} +{d} lines]", .{ self.next_id, lines }); + errdefer gpa.free(label); + const owned_body = try gpa.dupe(u8, body); + errdefer gpa.free(owned_body); + try self.entries.ensureUnusedCapacity(gpa, 1); + try buf.insertSlice(gpa, cursor.*, label); + self.edited(gpa, cursor.*, cursor.*, label.len); + self.entries.appendAssumeCapacity(.{ + .start = cursor.*, + .end = cursor.* + label.len, + .label = label, + .body = owned_body, + }); + std.mem.sort(Entry, self.entries.items, {}, lessThan); + self.next_id += 1; + cursor.* += label.len; + } + + /// Update semantic ranges after replacing `[from,to)` with `inserted_len` + /// bytes. Touching any part of a span detaches it; edits at either boundary + /// remain ordinary surrounding edits. + pub fn edited(self: *Store, gpa: Allocator, from: usize, to: usize, inserted_len: usize) void { + std.debug.assert(from <= to); + const removed_len = to - from; + var i: usize = 0; + while (i < self.entries.items.len) { + const entry = &self.entries.items[i]; + if (to <= entry.start) { + shift(entry, removed_len, inserted_len); + i += 1; + } else if (from >= entry.end) { + i += 1; + } else { + freeEntry(gpa, self.entries.orderedRemove(i)); + } + } + } + + /// Replace every live semantic span with its body, then consume the entries. + /// Lookalike text without an entry is copied verbatim, and a stale/corrupt + /// range is never expanded. + pub fn expand(self: *Store, gpa: Allocator, buf: *std.ArrayList(u8)) !void { + if (self.entries.items.len == 0) return; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(gpa); + var source: usize = 0; + var expanded = false; + for (self.entries.items) |entry| { + if (entry.start < source or entry.end > buf.items.len) continue; + if (!std.mem.eql(u8, buf.items[entry.start..entry.end], entry.label)) continue; + try out.appendSlice(gpa, buf.items[source..entry.start]); + try out.appendSlice(gpa, entry.body); + source = entry.end; + expanded = true; + } + if (!expanded) return; + try out.appendSlice(gpa, buf.items[source..]); + try buf.ensureTotalCapacity(gpa, out.items.len); + buf.clearRetainingCapacity(); + try buf.appendSlice(gpa, out.items); + self.clear(gpa); + } + + /// End byte for the live span beginning at `at`, used by readline's chip + /// renderer. Position plus label validation prevents a typed lookalike at a + /// different location from receiving attachment styling. + pub fn highlightEndAt(self: *const Store, items: []const u8, at: usize) ?usize { + for (self.entries.items) |entry| { + if (entry.start != at or entry.end > items.len) continue; + if (std.mem.eql(u8, items[entry.start..entry.end], entry.label)) return entry.end; + } + return null; + } + + pub fn left(self: *const Store, cursor: usize) usize { + for (self.entries.items) |entry| { + if (cursor > entry.start and cursor <= entry.end) return entry.start; + } + return cursor -| 1; + } + + pub fn right(self: *const Store, cursor: usize, len: usize) usize { + for (self.entries.items) |entry| { + if (cursor >= entry.start and cursor < entry.end) return entry.end; + } + return @min(cursor + 1, len); + } + + pub fn prevWord(self: *const Store, items: []const u8, cursor: usize) usize { + for (self.entries.items) |entry| { + if (cursor > entry.start and cursor <= entry.end) return entry.start; + } + const plain = plainPrevWord(items, cursor); + for (self.entries.items) |entry| { + if (plain > entry.start and plain < entry.end) return entry.start; + } + return plain; + } + + pub fn nextWord(self: *const Store, items: []const u8, cursor: usize) usize { + for (self.entries.items) |entry| { + if (cursor >= entry.start and cursor < entry.end) return entry.end; + } + const plain = plainNextWord(items, cursor); + for (self.entries.items) |entry| { + if (plain > entry.start and plain < entry.end) return entry.end; + } + return plain; + } +}; + +fn lessThan(_: void, a: Entry, b: Entry) bool { + return a.start < b.start; +} + +fn freeEntry(gpa: Allocator, entry: Entry) void { + gpa.free(entry.label); + gpa.free(entry.body); +} + +fn shift(entry: *Entry, removed_len: usize, inserted_len: usize) void { + if (inserted_len >= removed_len) { + const delta = inserted_len - removed_len; + entry.start += delta; + entry.end += delta; + } else { + const delta = removed_len - inserted_len; + entry.start -= delta; + entry.end -= delta; + } +} + +fn plainPrevWord(items: []const u8, cursor: usize) usize { + var i = cursor; + while (i > 0 and items[i - 1] == ' ') i -= 1; + while (i > 0 and items[i - 1] != ' ') i -= 1; + return i; +} + +fn plainNextWord(items: []const u8, cursor: usize) usize { + var i = cursor; + while (i < items.len and items[i] == ' ') i += 1; + while (i < items.len and items[i] != ' ') i += 1; + return i; +} + +fn deleteRange(buf: *std.ArrayList(u8), from: usize, to: usize) void { + std.mem.copyForwards(u8, buf.items[from..], buf.items[to..]); + buf.shrinkRetainingCapacity(buf.items.len - (to - from)); +} + +const testing = std.testing; + +test "paste span is highlighted and navigated as one unit" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + try buf.appendSlice(testing.allocator, "before after"); + var cursor: usize = 7; + try store.insert(testing.allocator, &buf, &cursor, "one\ntwo", 2); + const start: usize = 7; + const end = cursor; + + try testing.expectEqual(end, store.highlightEndAt(buf.items, start).?); + try testing.expectEqual(start, store.left(end)); + try testing.expectEqual(end, store.right(start, buf.items.len)); + try testing.expectEqual(start, store.prevWord(buf.items, end)); + try testing.expectEqual(end, store.nextWord(buf.items, start)); +} + +test "deleting a span prevents a typed lookalike from resurrecting its body" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "secret\nbody", 2); + const label = try testing.allocator.dupe(u8, buf.items); + defer testing.allocator.free(label); + + store.edited(testing.allocator, 0, cursor, 0); + deleteRange(&buf, 0, cursor); + try buf.appendSlice(testing.allocator, label); + store.edited(testing.allocator, 0, 0, label.len); + try store.expand(testing.allocator, &buf); + + try testing.expectEqual(@as(usize, 0), store.count()); + try testing.expectEqualStrings(label, buf.items); + try testing.expect(store.highlightEndAt(buf.items, 0) == null); +} + +test "two paste spans expand independently exactly once" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "alpha\nbeta", 2); + try buf.insertSlice(testing.allocator, cursor, " + "); + store.edited(testing.allocator, cursor, cursor, 3); + cursor += 3; + try store.insert(testing.allocator, &buf, &cursor, "gamma\ndelta", 2); + try store.expand(testing.allocator, &buf); + + try testing.expectEqualStrings("alpha\nbeta + gamma\ndelta", buf.items); +} + +test "removing one paste keeps and shifts the other" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "first\nbody", 2); + const first_end = cursor; + try buf.insertSlice(testing.allocator, cursor, " "); + store.edited(testing.allocator, cursor, cursor, 1); + cursor += 1; + try store.insert(testing.allocator, &buf, &cursor, "second\nbody", 2); + + store.edited(testing.allocator, 0, first_end, 0); + deleteRange(&buf, 0, first_end); + try store.expand(testing.allocator, &buf); + + try testing.expectEqual(@as(usize, 0), store.count()); + try testing.expectEqualStrings(" second\nbody", buf.items); +} + +test "surrounding insertions shift a span without detaching it" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "kept\nbody", 2); + try buf.insertSlice(testing.allocator, 0, "prefix "); + store.edited(testing.allocator, 0, 0, 7); + try buf.appendSlice(testing.allocator, " suffix"); + store.edited(testing.allocator, buf.items.len - 7, buf.items.len - 7, 7); + try store.expand(testing.allocator, &buf); + + try testing.expectEqualStrings("prefix kept\nbody suffix", buf.items); +} + +test "typed duplicate beside a live span stays literal and unhighlighted" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "real\nbody", 2); + const label = try testing.allocator.dupe(u8, buf.items); + defer testing.allocator.free(label); + try buf.append(testing.allocator, ' '); + try buf.appendSlice(testing.allocator, label); + store.edited(testing.allocator, cursor, cursor, label.len + 1); + + try testing.expect(store.highlightEndAt(buf.items, cursor + 1) == null); + try store.expand(testing.allocator, &buf); + try testing.expectEqualStrings("real\nbody [Pasted text #1 +2 lines]", buf.items); +} + +test "paste ids are not reused after an attachment is removed" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + try store.insert(testing.allocator, &buf, &cursor, "first\nbody", 2); + store.edited(testing.allocator, 0, cursor, 0); + deleteRange(&buf, 0, cursor); + cursor = 0; + try store.insert(testing.allocator, &buf, &cursor, "second\nbody", 2); + + try testing.expectEqualStrings("[Pasted text #2 +2 lines]", buf.items); +} + +test "expansion consumes identity even when the body begins with its label" { + var store: Store = .{}; + defer store.deinit(testing.allocator); + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(testing.allocator); + var cursor: usize = 0; + const body = "[Pasted text #1 +2 lines]\nX"; + try store.insert(testing.allocator, &buf, &cursor, body, 2); + + try store.expand(testing.allocator, &buf); + try store.expand(testing.allocator, &buf); + + try testing.expectEqual(@as(usize, 0), store.count()); + try testing.expectEqualStrings(body, buf.items); +} diff --git a/src/readline_replay.zig b/src/readline_replay.zig new file mode 100644 index 00000000..abfce227 --- /dev/null +++ b/src/readline_replay.zig @@ -0,0 +1,28 @@ +//! History-step replay for the raw readline composer. +//! +//! Replacing the draft invalidates semantic paste spans: history stores their +//! display labels, not their hidden bodies, so a replayed lookalike must remain +//! ordinary text rather than resurrecting an attachment (#673). + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Agent = @import("agent.zig").Agent; +const input_util = @import("input_util.zig"); +const PasteStore = @import("readline_paste.zig").Store; +const Step = @import("readline_history.zig").Step; + +pub fn apply( + root: *Agent, + gpa: Allocator, + buf: *std.ArrayList(u8), + cursor: *usize, + marks: *std.ArrayList([]const u8), + pastes: *PasteStore, + step: Step, +) void { + pastes.clear(gpa); + input_util.setLine(gpa, buf, step.text); + cursor.* = buf.items.len; + root.pending_image = step.image; + if (step.image != null) input_util.markImageChips(gpa, marks, buf.items); +} diff --git a/src/repl_convo.zig b/src/repl_convo.zig index 25e20e4c..b7600521 100644 --- a/src/repl_convo.zig +++ b/src/repl_convo.zig @@ -57,6 +57,16 @@ pub const Conversation = struct { return if (self.live) self.messages.items.len else 0; } + pub fn seed(self: *Conversation, source: std.json.Array) !void { + self.reset(); + self.messages = try cloneArray(self.alloc(), source); + self.live = true; + } + + pub fn cloneInto(self: *Conversation, dest: Allocator) !std.json.Array { + return cloneArray(dest, self.list().*); + } + /// `/new` and `/clear`: the session starts over, and the memory goes with /// it. Everything the old messages pointed at lived in this arena. pub fn reset(self: *Conversation) void { @@ -115,6 +125,15 @@ pub const Conversation = struct { } }; +fn cloneArray(a: Allocator, source: std.json.Array) !std.json.Array { + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.write(Value{ .array = source }); + const cloned = try std.json.parseFromSliceLeaky(Value, a, aw.writer.buffered(), .{ .allocate = .alloc_always }); + if (cloned != .array) return error.InvalidConversation; + return cloned.array; +} + fn textOf(a: Allocator, t: repl.Turn) !Value { return messages_mod.textMessage(a, switch (t.role) { .user => "user", diff --git a/src/repl_turn.zig b/src/repl_turn.zig index 624e9e98..23bff5a4 100644 --- a/src/repl_turn.zig +++ b/src/repl_turn.zig @@ -21,6 +21,8 @@ const messages_mod = @import("messages.zig"); const textMessage = messages_mod.textMessage; const prompts = @import("prompts.zig"); const providers = @import("providers.zig"); +const session = @import("session.zig"); +const util = @import("util.zig"); const repl = @import("repl.zig"); const repl_glue = @import("repl_glue.zig"); const ReplCtx = repl_glue.ReplCtx; @@ -74,6 +76,8 @@ pub fn turnAgent( .messages = std.json.Array.init(arena), .sub = false, // root: enables the full tool set + agentic loop .label = "repl", + .session_name = if (c.root) |root| root.session_name else "last", + .session_parent = if (c.root) |root| root.session_parent else null, .out = out, .in = null, // never prompt for tool approval / ask_user .stream_quiet = false, // stream tokens live into the repl pane @@ -109,10 +113,33 @@ pub fn turnAgent( .context_local_tokens = c.context_local_tokens, .last_cache_read = c.last_cache_read, }; + try seedSessionState(c, &agent, arena, params.goal); try prompts.setSystemPrompts(&agent, sys, arena); return agent; } +fn seedSessionState(c: *ReplCtx, agent: *Agent, arena: Allocator, goal_text: []const u8) !void { + const root = c.root orelse return; + if (goal_text.len > 0) { + if (root.goal) |goal| { + if (std.mem.eql(u8, goal.objective, goal_text)) { + var copy = goal; + copy.objective = try arena.dupe(u8, goal.objective); + agent.goal = copy; + for (root.todos.items) |todo| try agent.todos.append(arena, .{ + .content = try arena.dupe(u8, todo.content), + .status = try arena.dupe(u8, todo.status), + .epoch = todo.epoch, + .retired = todo.retired, + }); + return; + } + } + const now = util.unixMs(agent.io); + agent.goal = .{ .objective = try arena.dupe(u8, goal_text), .epoch = if (root.goal) |g| g.epoch + 1 else 1, .standing = true, .created_ms = now, .updated_ms = now }; + } +} + /// Give the turn's agent the session's history. With a conversation the agent /// BORROWS it — only the new prompt is folded in, so the request's prefix is /// byte-identical to last turn's (prompt caching) and the model still sees the @@ -154,8 +181,25 @@ fn promoteTailImages(agent: *Agent, cv: anytype, history: []const repl.Turn) !vo /// and every tool_use/tool_result pair to the borrowed list, and a managed /// ArrayList is a VALUE — not copying it back would drop the whole turn. fn returnHistory(c: *ReplCtx, agent: *Agent) void { - const cv = c.convo orelse return; - cv.list().* = agent.messages; + if (c.convo) |cv| cv.list().* = agent.messages; + const root = c.root orelse return; + root.strict = agent.strict; + root.ultracode_mode = agent.ultracode_mode; + root.last_context_tokens = agent.last_context_tokens; + root.context_local_tokens = agent.context_local_tokens; + root.last_cache_read = agent.last_cache_read; + root.goal = if (agent.goal) |goal| blk: { + var copy = goal; + copy.objective = root.arena.dupe(u8, goal.objective) catch break :blk root.goal; + break :blk copy; + } else null; + root.todos.clearRetainingCapacity(); + for (agent.todos.items) |todo| root.todos.append(root.arena, .{ + .content = root.arena.dupe(u8, todo.content) catch continue, + .status = root.arena.dupe(u8, todo.status) catch continue, + .epoch = todo.epoch, + .retired = todo.retired, + }) catch break; } /// repl.TurnFn — run a full ROOT agent turn (tools + MCP) for the chat @@ -210,11 +254,16 @@ pub fn replTurnCb(ctx_ptr: ?*anyopaque, gpa: Allocator, history: []const repl.Tu error.StreamStalled => return earlyEnd(gpa, &agent, "stream stalled"), // A mid-stream provider drop (#133), same handling as a stall. error.StreamDropped => return earlyEnd(gpa, &agent, "connection dropped"), + // The Responses WS path now preserves a bounded provider diagnostic. + // Return it as the turn result so the fullscreen TUI does not discard + // the live error and replace it with the misleading API-key fallback. + error.ApiError => return gpa.dupe(u8, agent.last_api_error orelse "provider API error") catch null, error.FallbackConsentRequired => return gpa.dupe(u8, "Saved model unavailable. Allow this provider with /fallback in the standard REPL, or choose another model.") catch null, else => return null, }; const trimmed = std.mem.trim(u8, final, " \t\r\n"); if (trimmed.len == 0) return null; + session.saveSessionAsync(&agent, arena, agent.session_name) catch {}; return gpa.dupe(u8, trimmed) catch null; } diff --git a/src/sandbox.zig b/src/sandbox.zig index 68e3b118..1506af4d 100644 --- a/src/sandbox.zig +++ b/src/sandbox.zig @@ -288,6 +288,38 @@ pub fn find(io: Io, dir: Io.Dir, arena: Allocator, session: []const u8, id: []co return parseManifest(arena, bytes); } +/// Delete one snapshot tree (manifest + payload). Missing is success: GC is +/// idempotent and must not fail a command because a half-written dir vanished. +pub fn remove(io: Io, dir: Io.Dir, arena: Allocator, session: []const u8, id: []const u8) bool { + if (!safeName(session) or !safeName(id)) return false; + const path = snapshotDir(arena, session, id) catch return false; + dir.access(io, path, .{}) catch return true; + dir.deleteTree(io, path) catch return false; + return true; +} + +pub const GcResult = struct { + kept: usize = 0, + removed: usize = 0, + bytes: u64 = 0, +}; + +/// Keep the newest `keep` snapshots (list is oldest-first); delete the rest. +/// `keep == 0` deletes every snapshot this session has on disk. +pub fn gc(io: Io, dir: Io.Dir, arena: Allocator, session: []const u8, keep: usize) Allocator.Error!GcResult { + const items = try list(io, dir, arena, session); + var result: GcResult = .{ .kept = items.len }; + if (items.len <= keep) return result; + for (items[0 .. items.len - keep]) |m| { + if (remove(io, dir, arena, session, m.id)) { + result.removed += 1; + result.bytes += m.len; + result.kept -= 1; + } + } + return result; +} + // ── The active sandbox ───────────────────────────────────────────────────── // A process-wide pair rather than an Agent field: nothing in the MVP attaches // a sandbox automatically, so the REPL commands need a seam they can read that @@ -435,6 +467,45 @@ test "LocalProcess acquires but refuses to snapshot" { handle.release(); } +test "snapshot GC keeps the newest N and deletes the rest" { + const io = std.testing.io; + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + + inline for (.{ + .{ .id = "old", .ms = 10, .len = 100 }, + .{ .id = "mid", .ms = 20, .len = 200 }, + .{ .id = "new", .ms = 30, .len = 300 }, + }) |row| { + try tmp.dir.createDirPath(io, try snapshotDir(arena, "s", row.id)); + try tmp.dir.writeFile(io, .{ .sub_path = try payloadPath(arena, "s", row.id), .data = "x" }); + try writeManifest(io, tmp.dir, arena, "s", .{ + .id = row.id, + .backend = "docker", + .kind = "docker_image_tar", + .ref = row.id, + .len = row.len, + .created_ms = row.ms, + }); + } + try std.testing.expectEqual(@as(usize, 3), (try list(io, tmp.dir, arena, "s")).len); + + const trimmed = try gc(io, tmp.dir, arena, "s", 1); + try std.testing.expectEqual(@as(usize, 2), trimmed.removed); + try std.testing.expectEqual(@as(usize, 1), trimmed.kept); + try std.testing.expectEqual(@as(u64, 300), trimmed.bytes); + const left = try list(io, tmp.dir, arena, "s"); + try std.testing.expectEqual(@as(usize, 1), left.len); + try std.testing.expectEqualStrings("new", left[0].id); + try std.testing.expect(find(io, tmp.dir, arena, "s", "old") == null); + try std.testing.expect(remove(io, tmp.dir, arena, "s", "missing")); + try std.testing.expect(!remove(io, tmp.dir, arena, "s", "..")); +} + test "the active sandbox is empty until something attaches one" { detach(); try std.testing.expect(active() == null); diff --git a/src/sandbox_docker.zig b/src/sandbox_docker.zig index 7ac54554..ef848373 100644 --- a/src/sandbox_docker.zig +++ b/src/sandbox_docker.zig @@ -52,12 +52,20 @@ pub const Docker = struct { /// backend; the wire below is unchanged. bin_name: []const u8 = "docker", - const backend_vtable: sandbox.BackendVTable = .{ + const docker_vtable: sandbox.BackendVTable = .{ .name = "docker", .available = availableImpl, .acquire = acquireImpl, .acquireFromSnapshot = acquireFromSnapshotImpl, }; + /// Same CLI wire, different binary — Apple Container (`container`) is the + /// second snapshot-capable backend #554's teleport path restores onto. + const container_vtable: sandbox.BackendVTable = .{ + .name = "container", + .available = availableImpl, + .acquire = acquireImpl, + .acquireFromSnapshot = acquireFromSnapshotImpl, + }; const handle_vtable: sandbox.HandleVTable = .{ .exec = execImpl, @@ -66,7 +74,10 @@ pub const Docker = struct { }; pub fn backend(self: *Docker) sandbox.Backend { - return .{ .ctx = @ptrCast(self), .vt = &backend_vtable }; + return .{ + .ctx = @ptrCast(self), + .vt = if (std.mem.eql(u8, self.bin_name, "container")) &container_vtable else &docker_vtable, + }; } /// One live sandbox. Allocated per `acquire` and freed by `release`, so two @@ -390,6 +401,13 @@ test "a snapshot tag is prefixed and unique" { try std.testing.expect(!std.mem.eql(u8, a, b)); } +test "bin_name container names the Apple Container backend" { + var docker: Docker = .{ .io = std.testing.io, .path_env = "", .bin_name = "container" }; + try std.testing.expectEqualStrings("container", docker.backend().name()); + var plain: Docker = .{ .io = std.testing.io, .path_env = "" }; + try std.testing.expectEqualStrings("docker", plain.backend().name()); +} + test "a missing docker resolves to nothing rather than spawning" { var buf: [4096]u8 = undefined; try std.testing.expect(resolveInto(std.testing.io, "", "docker", &buf) == null); diff --git a/src/sandbox_tests.zig b/src/sandbox_tests.zig index 0d6fa7bc..a77110e8 100644 --- a/src/sandbox_tests.zig +++ b/src/sandbox_tests.zig @@ -387,6 +387,138 @@ test "#554: /snapshot attach drives the seam by hand, and detach gives the sandb try std.testing.expectEqual(@as(usize, 1), (try sandbox.list(io, shim.tmp.dir, arena, "s")).len); } +test "#554: /snapshot gc keeps the newest snapshot and drops the rest" { + const io = std.testing.io; + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + sandbox.setWorkspace(tmp.dir); + defer sandbox.setWorkspace(null); + + inline for (.{ + .{ .id = "old", .ms = 10 }, + .{ .id = "new", .ms = 20 }, + }) |row| { + try tmp.dir.createDirPath(io, try sandbox.snapshotDir(arena, "s", row.id)); + try tmp.dir.writeFile(io, .{ .sub_path = try sandbox.payloadPath(arena, "s", row.id), .data = canned_tar }); + try sandbox.writeManifest(io, tmp.dir, arena, "s", .{ + .id = row.id, + .backend = "docker", + .kind = "docker_image_tar", + .ref = row.id, + .len = canned_tar.len, + .created_ms = row.ms, + }); + } + + var root: Agent = undefined; + root.io = io; + root.gpa = gpa; + root.session_name = "s"; + var aw: Io.Writer.Allocating = .init(arena); + try std.testing.expect(try commands_sandbox.tryHandle(&root, arena, "/snapshot gc", &aw.writer)); + try std.testing.expect(std.mem.indexOf(u8, aw.writer.buffered(), "dropped 1") != null); + const left = try sandbox.list(io, tmp.dir, arena, "s"); + try std.testing.expectEqual(@as(usize, 1), left.len); + try std.testing.expectEqualStrings("new", left[0].id); +} + +test "#554: /teleport restores a docker_image_tar onto the container CLI" { + if (skipOnWindows()) return error.SkipZigTest; + const io = std.testing.io; + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var shim: Shim = undefined; + try shim.init(); + defer shim.deinit(); + try shim.tmp.dir.writeFile(io, .{ + .sub_path = "bin/container", + .data = shim_script, + .flags = .{ .permissions = .executable_file }, + }); + sandbox.setWorkspace(shim.tmp.dir); + defer sandbox.setWorkspace(null); + defer releaseActive(); + const saved_path = main_mod.g_path_env; + defer main_mod.g_path_env = saved_path; + main_mod.g_path_env = shim.bin_dir; + + try shim.tmp.dir.createDirPath(io, try sandbox.snapshotDir(arena, "s", "snap1")); + try shim.tmp.dir.writeFile(io, .{ + .sub_path = try sandbox.payloadPath(arena, "s", "snap1"), + .data = canned_tar, + }); + try sandbox.writeManifest(io, shim.tmp.dir, arena, "s", .{ + .id = "snap1", + .backend = "docker", + .kind = "docker_image_tar", + .ref = "graff-snap-from-manifest", + .len = canned_tar.len, + .created_ms = 1, + }); + shim.clearLog(); + + var root: Agent = undefined; + root.io = io; + root.gpa = gpa; + root.session_name = "s"; + var aw: Io.Writer.Allocating = .init(arena); + try std.testing.expect(try commands_sandbox.tryHandle(&root, arena, "/teleport snap1 container", &aw.writer)); + const text = aw.writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, text, "teleported") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "onto container") != null); + try std.testing.expect(std.mem.indexOf(u8, text, commands_sandbox.log_note) != null); + try std.testing.expectEqualStrings("container", sandbox.active().?.backend.name()); + const log = shim.log(arena); + try std.testing.expect(std.mem.indexOf(u8, log, "load") != null); + try std.testing.expect(std.mem.indexOf(u8, log, "run -d --name graff-sbx-s " ++ loaded_ref) != null); +} + +test "#554: /teleport with no dest CLI explains instead of failing" { + const gpa = std.testing.allocator; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + sandbox.setWorkspace(tmp.dir); + defer sandbox.setWorkspace(null); + sandbox.detach(); + const saved_path = main_mod.g_path_env; + defer main_mod.g_path_env = saved_path; + main_mod.g_path_env = ""; + + try tmp.dir.createDirPath(std.testing.io, try sandbox.snapshotDir(arena, "s", "snap1")); + try sandbox.writeManifest(std.testing.io, tmp.dir, arena, "s", .{ + .id = "snap1", + .backend = "docker", + .kind = "docker_image_tar", + .ref = "x", + .len = 1, + .created_ms = 1, + }); + + var root: Agent = undefined; + root.io = std.testing.io; + root.gpa = gpa; + root.session_name = "s"; + var aw: Io.Writer.Allocating = .init(arena); + try std.testing.expect(try commands_sandbox.tryHandle(&root, arena, "/teleport snap1 container", &aw.writer)); + try std.testing.expect(std.mem.indexOf(u8, aw.writer.buffered(), "container backend:") != null); + try std.testing.expect(std.mem.indexOf(u8, aw.writer.buffered(), "not on PATH") != null); + try std.testing.expect(sandbox.active() == null); + + var usage: Io.Writer.Allocating = .init(arena); + try std.testing.expect(try commands_sandbox.tryHandle(&root, arena, "/teleport", &usage.writer)); + try std.testing.expect(std.mem.indexOf(u8, usage.writer.buffered(), "usage: /teleport") != null); +} + test "#554: /snapshot attach with no docker installed explains instead of failing" { const gpa = std.testing.allocator; var arena_state: std.heap.ArenaAllocator = .init(gpa); diff --git a/src/session.zig b/src/session.zig index 6dfae8e2..175ce24d 100644 --- a/src/session.zig +++ b/src/session.zig @@ -39,7 +39,10 @@ const utf8Prefix = util.utf8Prefix; // `session.sessionPath`, `session.listSavedSessions`, and friends. const session_index = @import("session_index.zig"); pub const session_ext = session_index.session_ext; +pub const sessions_dir = session_index.sessions_dir; pub const sessionPath = session_index.sessionPath; +pub const validSessionName = session_index.validSessionName; +pub const sessionExists = session_index.sessionExists; pub const SessionMeta = session_index.SessionMeta; pub const sessionMetaFromBytes = session_index.sessionMetaFromBytes; pub const sessionMeta = session_index.sessionMeta; @@ -159,6 +162,7 @@ fn fingerprint(root: *Agent, name: []const u8) u64 { f.num(t.epoch); f.flag(t.retired); // #394: retiring a finished checklist is a real state change, so it must reach disk } + if (root.session_parent) |parent| f.text(parent) else f.flag(false); f.text(root.session_title orelse sessionTitle(root)); // The persisted meter's two inputs. Its third (system prompt + tool schema // size) shifts `context_tokens` and `context_local_tokens` together, and @@ -294,6 +298,8 @@ fn queueSave(root: *Agent, arena: Allocator, dir: Io.Dir, name: []const u8) !u64 try s.endObject(); } try s.endArray(); + try s.objectField("parent"); + if (root.session_parent) |parent| try s.write(parent) else try s.write(null); try s.objectField("title"); if (root.session_title) |title| try s.write(title) else try s.write(sessionTitle(root)); try s.objectField("updated_ms"); @@ -439,6 +445,7 @@ pub fn loadSession(root: *Agent, keys: *Keys, arena: Allocator, name: []const u8 const strict = if (obj.get("strict")) |v| (v == .bool and v.bool) else false; const ultracode_mode = if (obj.get("ultracode_mode")) |v| (v == .bool and v.bool) else false; const goal: ?agent_mod.Goal = if (obj.get("goal")) |v| goalFromValue(v, unixMs(root.io)) else null; + const parent = if (obj.get("parent")) |v| (if (v == .string and v.string.len > 0) v.string else null) else null; const title = if (obj.get("title")) |v| (if (v == .string and v.string.len > 0) v.string else null) else null; // Optional for backward compatibility with sessions written before context // metering was persisted. JSON integers are signed; ignore negative/wrong-type @@ -497,6 +504,7 @@ pub fn loadSession(root: *Agent, keys: *Keys, arena: Allocator, name: []const u8 root.pending_goal_note = null; root.goal_note_fp = 0; root.goal_note_age = 0; + root.session_parent = parent; root.session_title = title; // Rebase the saved server-only delta onto today's prompt/tool-schema input. restoreContextMeter(root, saved_context_tokens, saved_local_tokens); diff --git a/src/session_branch.zig b/src/session_branch.zig new file mode 100644 index 00000000..c93044cc --- /dev/null +++ b/src/session_branch.zig @@ -0,0 +1,75 @@ +//! Clone-on-write session resume shared by the line REPL, TUI, and startup. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; + +const agent_mod = @import("agent.zig"); +const provider_mod = @import("provider.zig"); +const session = @import("session.zig"); +const http_headers = @import("http_headers.zig"); +const prompts = @import("prompts.zig"); +const goal_flow = @import("goal_flow.zig"); +const util = @import("util.zig"); + +pub const Error = error{ + InvalidSessionName, + BranchMatchesSource, + BranchAlreadyExists, +}; + +pub const Result = struct { + source: []const u8, + target: []const u8, + branched: bool, +}; + +pub const Spec = struct { source: []const u8, branch: ?[]const u8 }; + +pub fn parseSpec(raw: []const u8) ?Spec { + const arg = std.mem.trim(u8, raw, " \t"); + const marker = " --branch "; + if (std.mem.endsWith(u8, arg, " --branch")) return null; + const split = std.mem.indexOf(u8, arg, marker) orelse return .{ .source = arg, .branch = null }; + const source = std.mem.trim(u8, arg[0..split], " \t"); + const branch = std.mem.trim(u8, arg[split + marker.len ..], " \t"); + if (source.len == 0 or branch.len == 0 or std.mem.indexOf(u8, branch, marker) != null) return null; + return .{ .source = source, .branch = branch }; +} + +pub fn restore(root: *agent_mod.Agent, keys: *provider_mod.Keys, arena: Allocator, source_raw: []const u8, branch_raw: ?[]const u8) !Result { + const source = try arena.dupe(u8, source_raw); + if (!session.validSessionName(source)) return Error.InvalidSessionName; + const branch = if (branch_raw) |raw| try arena.dupe(u8, raw) else null; + var reserved_path: ?[]const u8 = null; + if (branch) |dest| { + if (!session.validSessionName(dest)) return Error.InvalidSessionName; + if (std.mem.eql(u8, source, dest)) return Error.BranchMatchesSource; + if (session.sessionExists(root, arena, dest)) return Error.BranchAlreadyExists; + try Io.Dir.cwd().createDirPath(root.io, session.sessions_dir); + const path = try session.sessionPath(arena, dest); + const claim = Io.Dir.cwd().createFile(root.io, path, .{ .exclusive = true }) catch |err| switch (err) { + error.PathAlreadyExists => return Error.BranchAlreadyExists, + else => return err, + }; + claim.close(root.io); + reserved_path = path; + } + errdefer if (reserved_path) |path| Io.Dir.cwd().deleteFile(root.io, path) catch {}; + + root.ensureStoredKeys(keys); + try session.loadSession(root, keys, arena, source); + root.session_name = branch orelse source; + if (branch) |dest| { + root.session_parent = source; + _ = http_headers.renewSessionId(root.io); + try session.saveSession(root, arena, dest); + reserved_path = null; + } + prompts.resetSessionCompacted(root, arena); + if (root.goal_flag) |g| { + root.pending_goal_note = goal_flow.reapplyFlagGoal(arena, root, g, util.unixMs(root.io)) catch null; + prompts.pinStandingGoal(root, arena); + } + return .{ .source = source, .target = root.session_name, .branched = branch != null }; +} diff --git a/src/session_index.zig b/src/session_index.zig index 92efc926..166a2f16 100644 --- a/src/session_index.zig +++ b/src/session_index.zig @@ -47,12 +47,17 @@ pub fn sessionPath(arena: Allocator, name: []const u8) ![]const u8 { return std.fmt.allocPrint(arena, "{s}/{s}{s}", .{ sessions_dir, name, session_ext }); } +pub fn validSessionName(name: []const u8) bool { + if (name.len == 0 or name.len > 128 or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return false; + return std.mem.indexOfAny(u8, name, "/\\\r\n\x00") == null; +} + /// Session-list metadata peeked from a session file WITHOUT parsing the /// (potentially multi-MB) messages array: saveSession writes "title" and /// "updated_ms" before "messages", so parsing the header slice alone is /// enough. Zero-value fields when the file predates them or the header /// can't be read — callers fall back to the raw session name (#109). -pub const SessionMeta = struct { title: ?[]const u8 = null, updated_ms: i64 = 0 }; +pub const SessionMeta = struct { title: ?[]const u8 = null, parent: ?[]const u8 = null, updated_ms: i64 = 0 }; pub fn sessionMetaFromBytes(arena: Allocator, data: []const u8) SessionMeta { // Embedded quotes inside string values are escaped in the file, so the @@ -65,6 +70,7 @@ pub fn sessionMetaFromBytes(arena: Allocator, data: []const u8) SessionMeta { if (parsed != .object) return .{}; return .{ .title = if (parsed.object.get("title")) |v| (if (v == .string and v.string.len > 0) v.string else null) else null, + .parent = if (parsed.object.get("parent")) |v| (if (v == .string and v.string.len > 0) v.string else null) else null, .updated_ms = if (parsed.object.get("updated_ms")) |v| (if (v == .integer) v.integer else 0) else 0, }; } @@ -75,6 +81,13 @@ pub fn sessionMeta(root: *Agent, arena: Allocator, base: []const u8) SessionMeta return sessionMetaFromBytes(arena, data); } +pub fn sessionExists(root: *Agent, arena: Allocator, base: []const u8) bool { + const path = sessionPath(arena, base) catch return false; + if ((Io.Dir.cwd().statFile(root.io, path, .{}) catch null) != null) return true; + const legacy = std.fmt.allocPrint(arena, "{s}{s}", .{ base, session_ext }) catch return false; + return (Io.Dir.cwd().statFile(root.io, legacy, .{}) catch null) != null; +} + /// "3m ago"-style age for the session lists; "" when the timestamp is missing. pub fn sessionAge(arena: Allocator, io: Io, then_ms: i64) []const u8 { if (then_ms <= 0) return ""; @@ -87,7 +100,7 @@ pub fn sessionAge(arena: Allocator, io: Io, then_ms: i64) []const u8 { /// One row per saved session for the /resume picker and /sessions list: /// newest first, keyed (and resumed) by the file base name. -pub const SessionEntry = struct { base: []const u8, title: ?[]const u8 = null, updated_ms: i64 = 0 }; +pub const SessionEntry = struct { base: []const u8, title: ?[]const u8 = null, parent: ?[]const u8 = null, updated_ms: i64 = 0 }; pub fn listSavedSessions(root: *Agent, arena: Allocator) std.ArrayList(SessionEntry) { var entries: std.ArrayList(SessionEntry) = .empty; @@ -99,7 +112,7 @@ pub fn listSavedSessions(root: *Agent, arena: Allocator) std.ArrayList(SessionEn if (!std.mem.endsWith(u8, entry.name, session_ext)) continue; const base = arena.dupe(u8, entry.name[0 .. entry.name.len - session_ext.len]) catch continue; const meta = sessionMeta(root, arena, base); - entries.append(arena, .{ .base = base, .title = meta.title, .updated_ms = meta.updated_ms }) catch {}; + entries.append(arena, .{ .base = base, .title = meta.title, .parent = meta.parent, .updated_ms = meta.updated_ms }) catch {}; } std.mem.sort(SessionEntry, entries.items, {}, struct { fn newerFirst(_: void, a: SessionEntry, b: SessionEntry) bool { @@ -135,9 +148,10 @@ test "sessionMetaFromBytes reads title + updated_ms from the header only" { defer arena_state.deinit(); const arena = arena_state.allocator(); const meta = sessionMetaFromBytes(arena, - \\{"provider":"codegraff","model":"glm-5.2","strict":false,"ultracode_mode":false,"goal":null,"title":"Fix \"login\" bug","updated_ms":1782294417239,"messages":[{"role":"user","content":"hi"}]} + \\{"provider":"codegraff","model":"glm-5.2","strict":false,"ultracode_mode":false,"goal":null,"parent":"baseline","title":"Fix \"login\" bug","updated_ms":1782294417239,"messages":[{"role":"user","content":"hi"}]} ); try std.testing.expectEqualStrings("Fix \"login\" bug", meta.title.?); + try std.testing.expectEqualStrings("baseline", meta.parent.?); try std.testing.expectEqual(@as(i64, 1782294417239), meta.updated_ms); } diff --git a/src/session_run.zig b/src/session_run.zig index 45597fb4..11d41c87 100644 --- a/src/session_run.zig +++ b/src/session_run.zig @@ -52,6 +52,7 @@ const eval_memory = @import("eval_memory.zig"); const providers = @import("providers.zig"); const messages_mod = @import("messages.zig"); const session = @import("session.zig"); +const session_branch = @import("session_branch.zig"); const session_settings = @import("session_settings.zig"); const presence = @import("presence.zig"); const proc_identity = @import("proc_identity.zig"); @@ -85,6 +86,9 @@ pub fn runReplCommand(gpa: Allocator, io: Io, environ_map: anytype, root: *agent try root.ensureRootTools(.anthropic); try root.ensureRootTools(.openai); try root.ensureRootTools(.responses); + var convo = repl_glue.Conversation.init(gpa); + defer convo.deinit(); + try convo.seed(root.messages); var repl_ctx = repl_glue.ReplCtx{ .io = io, .client = client, @@ -101,6 +105,8 @@ pub fn runReplCommand(gpa: Allocator, io: Io, environ_map: anytype, root: *agent .tools_anthropic = root.tools_anthropic, .tools_openai = root.tools_openai, .tools_responses = root.tools_responses, + .convo = &convo, + .root = root, }; var models_buf = std.array_list.Managed(u8).init(arena); for (pricing.models()) |mi| { @@ -112,9 +118,23 @@ pub fn runReplCommand(gpa: Allocator, io: Io, environ_map: anytype, root: *agent // Same stderr footer as `-p`, so evals can score the scripted REPL the // same way. TTY `graff repl` is the TUI and keeps the restore tail clean. pricing.printUsageFooter(io); + root.messages = try convo.cloneInto(root.arena); return true; } +pub fn runFrontendCommands(gpa: Allocator, io: Io, environ_map: anytype, root: *agent_mod.Agent, keys: *provider_mod.Keys, client: *std.http.Client, in: *Io.Reader, out: *Io.Writer, arena: Allocator, flags: args.Flags, json_mode: bool, cwd: []const u8, final_io: Io) !bool { + if (try runReplCommand(gpa, io, environ_map, root, keys, client, in, out, arena, flags)) { + try finalizeSession(gpa, final_io, arena, out, root, json_mode); + return true; + } + if (try @import("acp.zig").runAcpCommand(gpa, io, environ_map, root, keys, client, in, out, arena, flags)) return true; + if (try tui_launch.maybeRun(gpa, io, environ_map, root, keys, client, arena, flags, json_mode, cwd)) { + try finalizeSession(gpa, final_io, arena, out, root, json_mode); + return true; + } + return false; +} + /// One-shot print mode (`-p`/bare positional prompt): run the single prompt /// to completion, print the final text to stdout, exit. Tool progress goes /// to stderr (say() with no out writer), paint stays quiet, and the gate @@ -317,7 +337,8 @@ pub fn buildRootAgent( // sharing a session name — which would also share one .session.json file // (#289 contention) and collide as presence peers (#469). const fresh_session_name = try std.fmt.allocPrint(arena, "session-{d}-{d}", .{ util.unixMs(io), proc_identity.selfPid() }); - root.session_name = if (flags.resume_flag) |name| (if (!flags.new_session_flag and !flags.no_resume_flag) name else fresh_session_name) else fresh_session_name; + root.session_name = if (flags.resume_flag != null and !flags.new_session_flag and !flags.no_resume_flag) (flags.branch_flag orelse flags.resume_flag.?) else fresh_session_name; + root.session_parent = if (flags.branch_flag != null) flags.resume_flag else null; try prompts.setRootSystemPrompts(&root, sys_normal, arena); // #381: same funnel + the live .graff/playbook.jsonl constraint block local_tools.load(io, arena); // Startup pays for one provider format, not all three. Other formats are @@ -380,13 +401,9 @@ pub fn buildRootAgent( pub fn saveOrResumeSession(root: *agent_mod.Agent, keys: *provider_mod.Keys, arena: Allocator, flags: args.Flags) void { const will_resume = flags.resume_flag != null and !flags.new_session_flag and !flags.no_resume_flag; if (!will_resume) session.saveSession(root, arena, root.session_name) catch {}; - if (flags.oneshot_prompt != null and flags.resume_flag != null and !flags.new_session_flag and !flags.no_resume_flag) { - session.loadSession(root, keys, arena, root.session_name) catch {}; - // loadSession overwrote root.goal; the flag wins, idempotently (#318). - if (root.goal_flag) |g| { - root.pending_goal_note = goal_flow.reapplyFlagGoal(arena, root, g, util.unixMs(root.io)) catch null; - prompts.pinStandingGoal(root, arena); - } + if (flags.oneshot_prompt != null and will_resume) { + const source = flags.resume_flag.?; + _ = session_branch.restore(root, keys, arena, source, flags.branch_flag) catch |err| std.process.fatal("cannot resume/branch from '{s}': {t}", .{ source, err }); } } @@ -398,12 +415,8 @@ pub fn saveOrResumeSession(root: *agent_mod.Agent, keys: *provider_mod.Keys, are /// main()-owned storage. pub fn restoreResumedSession(arena: Allocator, out: *Io.Writer, root: *agent_mod.Agent, keys: *provider_mod.Keys, flags: args.Flags, json_mode: bool, cwd_display: []const u8) !void { if (!(flags.oneshot_prompt == null and flags.resume_flag != null and !flags.new_session_flag and !flags.no_resume_flag)) return; - if (session.loadSession(root, keys, arena, root.session_name)) |_| { - // --goal outranks the restored goal here too, idempotently (#318). - if (root.goal_flag) |g| { - root.pending_goal_note = goal_flow.reapplyFlagGoal(arena, root, g, util.unixMs(root.io)) catch null; - prompts.pinStandingGoal(root, arena); - } + const source = flags.resume_flag.?; + if (session_branch.restore(root, keys, arena, source, flags.branch_flag)) |_| { if (root.messages.items.len > 0) { if (!json_mode) { // Prefer the saved AI summary; fall back to the first user @@ -412,11 +425,16 @@ pub fn restoreResumedSession(arena: Allocator, out: *Io.Writer, root: *agent_mod title_mod.setTerminalTitle(out, restored_title, cwd_display); try title_mod.printSessionHeader(out, restored_title, cwd_display); root.tui_header_shown = true; - try out.print("↩ resumed {s}{s} — {d} message(s) on {s} · /new or /clear for a fresh start\n", .{ root.session_name, session.session_ext, root.messages.items.len, root.provider.model }); + if (flags.branch_flag) |dest| + try out.print("↩ branched {s}{s} → {s}{s} — {d} message(s) on {s}\n", .{ source, session.session_ext, dest, session.session_ext, root.messages.items.len, root.provider.model }) + else + try out.print("↩ resumed {s}{s} — {d} message(s) on {s} · /new or /clear for a fresh start\n", .{ source, session.session_ext, root.messages.items.len, root.provider.model }); try out.flush(); } } - } else |_| {} + } else |err| { + if (flags.branch_flag != null) std.process.fatal("cannot branch from '{s}': {t}", .{ source, err }); + } } /// Summarize a large restored context only after behavioral lifecycle start. diff --git a/src/test_hooks.zig b/src/test_hooks.zig index 3830d402..987e5196 100644 --- a/src/test_hooks.zig +++ b/src/test_hooks.zig @@ -34,6 +34,7 @@ const recipe = @import("recipe.zig"); const repl = @import("repl.zig"); const repl_markdown = @import("repl_markdown.zig"); const repl_parser = @import("repl_parser.zig"); +const tui_session = @import("tui_session.zig"); // Routing + worker selection. const router_config = @import("router_config.zig"); @@ -221,7 +222,7 @@ const proc_identity = @import("proc_identity.zig"); // (agent_context.zig, subagent.zig) sit at the 600-line cap. const usage_attribution_tests = @import("usage_attribution_tests.zig"); -// #554: the sandbox seam, its Docker backend, the /snapshot + /rewind +// #554: the sandbox seam, its Docker backend, /snapshot + /rewind + /teleport + gc // commands, and the fake-docker orchestration suite. Production reaches // commands_sandbox.zig from commands_misc.tryHandle and the rest only through // calls, so without these hooks the whole subsystem's tests compile to nothing. @@ -241,6 +242,7 @@ test { _ = repl; _ = repl_markdown; _ = repl_parser; + _ = tui_session; _ = router_config; _ = subagent_selection; _ = subagent_pin_tests; @@ -343,4 +345,8 @@ test { _ = @import("channel_worker.zig"); _ = @import("session_wake.zig"); _ = @import("tui_acp.zig"); + _ = @import("readline_paste.zig"); // #674: semantic paste spans (not reached via readline.zig's runtime import) + _ = @import("agent_request_scratch.zig"); // #693: split out of agent_request.zig (600-line ceiling) + _ = @import("commands_resume.zig"); // #697: --resume SOURCE --branch DEST parser + _ = @import("net_efficiency_test.zig"); } diff --git a/src/tui_launch.zig b/src/tui_launch.zig index a4a2b0ef..cbbaa79e 100644 --- a/src/tui_launch.zig +++ b/src/tui_launch.zig @@ -15,8 +15,10 @@ const process_runner = @import("process_runner.zig"); const repl = @import("repl.zig"); const repl_bash = @import("repl_bash.zig"); const repl_glue = @import("repl_glue.zig"); +const session = @import("session.zig"); const tui = @import("tui"); const tui_peer = @import("tui_peer.zig"); +const tui_session = @import("tui_session.zig"); const engine_sink = @import("engine_sink.zig"); const tui_sink = @import("tui_sink.zig"); const tui_acp = @import("tui_acp.zig"); @@ -100,7 +102,9 @@ pub fn run( // created here, on the frame that owns the whole TUI session. var convo = repl_glue.Conversation.init(gpa); defer convo.deinit(); + try tui_session.seed(&convo, root); repl_ctx.convo = &convo; + const initial_history = try tui_session.visibleTurns(arena, root.messages); const entries = modelEntries(arena, keys.*); engine_sink.hosted_frontend = true; defer engine_sink.hosted_frontend = false; @@ -117,6 +121,11 @@ pub fn run( .cancel_fn = cancelCb, .model_name = root.provider.model, .model_provider = root.provider.id, + .initial_history = initial_history, + .session_name = root.session_name, + .initial_goal = if (root.goal) |goal| goal.objective else "", + .initial_strict = root.strict, + .initial_ultracode = root.ultracode_mode, .model_entries = entries, .cwd = cwd, .yolo = yolo, @@ -127,9 +136,40 @@ pub fn run( .copy_fn = copyCb, .compact_fn = compactCb, .history_fn = historyCb, + .resume_fn = tui_session.resumeCb, + .state_fn = stateCb, + .emergency_fn = emergencyCb, .idle_wake_fn = idleWakeCb, .peer_fn = tui_peer.peerCb, }); + try tui_session.syncRoot(&convo, root); +} + +fn stateCb(ctx: ?*anyopaque, state: tui.SessionState) void { + const c: *repl_glue.ReplCtx = @ptrCast(@alignCast(ctx orelse return)); + const root = c.root orelse return; + root.strict = state.strict; + root.ultracode_mode = state.ultracode; + if (state.session_name.len > 0 and !std.mem.eql(u8, state.session_name, root.session_name)) + root.session_name = root.arena.dupe(u8, state.session_name) catch root.session_name; + if (state.goal.len == 0) { + root.goal = null; + root.todos.clearRetainingCapacity(); + } else if (root.goal == null or !std.mem.eql(u8, root.goal.?.objective, state.goal)) { + const now = util.unixMs(root.io); + root.goal = .{ + .objective = root.arena.dupe(u8, state.goal) catch return, + .epoch = if (root.goal) |goal| goal.epoch + 1 else 1, + .standing = true, + .created_ms = now, + .updated_ms = now, + }; + root.todos.clearRetainingCapacity(); + } +} + +fn emergencyCb(_: ?*anyopaque) void { + session.flushSaves(); } /// The transcript was cut, so cut the conversation the same way: /new starts diff --git a/src/tui_session.zig b/src/tui_session.zig new file mode 100644 index 00000000..080971ce --- /dev/null +++ b/src/tui_session.zig @@ -0,0 +1,123 @@ +//! Durable-session projection for the fullscreen in-process ACP client. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Value = std.json.Value; + +const agent_mod = @import("agent.zig"); +const repl_glue = @import("repl_glue.zig"); +const session = @import("session.zig"); +const session_branch = @import("session_branch.zig"); +const tui = @import("tui"); + +pub fn seed(convo: *repl_glue.Conversation, root: *agent_mod.Agent) !void { + try convo.seed(root.messages); +} + +pub fn syncRoot(convo: *repl_glue.Conversation, root: *agent_mod.Agent) !void { + root.messages = try convo.cloneInto(root.arena); +} + +pub fn visibleTurns(arena: Allocator, messages: std.json.Array) ![]tui.Turn { + var turns: std.ArrayList(tui.Turn) = .empty; + for (messages.items) |message| { + const role = visibleRole(message) orelse continue; + const text = try visibleText(arena, message); + if (text.len == 0) continue; + try turns.append(arena, .{ .role = role, .text = text }); + } + return try turns.toOwnedSlice(arena); +} + +fn visibleRole(message: Value) ?tui.Turn.Role { + if (message != .object) return null; + const role = message.object.get("role") orelse return null; + if (role != .string) return null; + if (std.mem.eql(u8, role.string, "user")) { + if (message.object.get("content")) |content| if (content == .array) for (content.array.items) |block| { + if (block != .object) continue; + const ty = block.object.get("type") orelse continue; + if (ty == .string and std.mem.eql(u8, ty.string, "tool_result")) return null; + }; + return .user; + } + if (std.mem.eql(u8, role.string, "assistant")) return .assistant; + return null; +} + +fn visibleText(arena: Allocator, message: Value) ![]const u8 { + const content = message.object.get("content") orelse return ""; + if (content == .string) return arena.dupe(u8, content.string); + if (content != .array) return ""; + var out: std.ArrayList(u8) = .empty; + for (content.array.items) |block| { + if (block != .object) continue; + const text = block.object.get("text") orelse continue; + if (text != .string or text.string.len == 0) continue; + if (out.items.len > 0) try out.append(arena, '\n'); + try out.appendSlice(arena, text.string); + } + return try out.toOwnedSlice(arena); +} + +fn failure(gpa: Allocator, out: *tui.ResumeOut, err: anyerror) bool { + out.note = std.fmt.allocPrint(gpa, "resume failed: {t}", .{err}) catch &.{}; + return false; +} + +pub fn resumeCb(ctx_ptr: ?*anyopaque, gpa: Allocator, raw: []const u8, out: *tui.ResumeOut) bool { + const ctx: *repl_glue.ReplCtx = @ptrCast(@alignCast(ctx_ptr orelse return failure(gpa, out, error.NoSession))); + const root = ctx.root orelse return failure(gpa, out, error.NoSession); + const spec = session_branch.parseSpec(raw) orelse return failure(gpa, out, error.InvalidSessionName); + if (spec.source.len == 0) return failure(gpa, out, error.InvalidSessionName); + + if (ctx.convo) |convo| { + syncRoot(convo, root) catch |err| return failure(gpa, out, err); + session.saveSession(root, root.arena, root.session_name) catch |err| return failure(gpa, out, err); + } + const resumed = session_branch.restore(root, &ctx.keys, root.arena, spec.source, spec.branch) catch |err| return failure(gpa, out, err); + if (ctx.convo) |convo| seed(convo, root) catch |err| return failure(gpa, out, err); + ctx.provider = root.provider; + ctx.last_context_tokens = root.last_context_tokens; + ctx.context_local_tokens = root.context_local_tokens; + ctx.last_cache_read = root.last_cache_read; + tui.setCurrentModel(root.provider.model, root.provider.id); + out.turns = visibleTurns(gpa, root.messages) catch |err| return failure(gpa, out, err); + out.session_name = gpa.dupe(u8, resumed.target) catch return failure(gpa, out, error.OutOfMemory); + out.goal = if (root.goal) |goal| gpa.dupe(u8, goal.objective) catch return failure(gpa, out, error.OutOfMemory) else ""; + out.strict = root.strict; + out.ultracode = root.ultracode_mode; + out.note = if (resumed.branched) + std.fmt.allocPrint(gpa, "branched {s} → {s}", .{ resumed.source, resumed.target }) catch &.{} + else + std.fmt.allocPrint(gpa, "resumed {s}", .{resumed.source}) catch &.{}; + return true; +} + +test "visible turns keep human text and omit provider tool envelopes" { + const arena = std.testing.allocator; + var messages = std.json.Array.init(arena); + defer messages.deinit(); + var user: std.json.ObjectMap = .empty; + defer user.deinit(arena); + try user.put(arena, "role", .{ .string = "user" }); + try user.put(arena, "content", .{ .string = "baseline" }); + try messages.append(.{ .object = user }); + var tool_result: std.json.ObjectMap = .empty; + defer tool_result.deinit(arena); + try tool_result.put(arena, "type", .{ .string = "tool_result" }); + try tool_result.put(arena, "content", .{ .string = "secret tool output" }); + var tool_content = std.json.Array.init(arena); + defer tool_content.deinit(); + try tool_content.append(.{ .object = tool_result }); + var provider_user: std.json.ObjectMap = .empty; + defer provider_user.deinit(arena); + try provider_user.put(arena, "role", .{ .string = "user" }); + try provider_user.put(arena, "content", .{ .array = tool_content }); + try messages.append(.{ .object = provider_user }); + const turns = try visibleTurns(arena, messages); + defer arena.free(turns); + defer arena.free(turns[0].text); + try std.testing.expectEqual(@as(usize, 1), turns.len); + try std.testing.expectEqualStrings("baseline", turns[0].text); +} diff --git a/src/ws.zig b/src/ws.zig index 186fa5af..54839878 100644 --- a/src/ws.zig +++ b/src/ws.zig @@ -21,6 +21,7 @@ const net = std.Io.net; const HostName = net.HostName; const Allocator = std.mem.Allocator; const tls = std.crypto.tls; +const http_warm = @import("http_warm.zig"); /// GRAFF_WS_DEBUG=1 → dump the handshake + frame headers to stderr. pub var g_debug: bool = false; @@ -76,8 +77,6 @@ pub const WsClient = struct { r: *Io.Reader = undefined, w: *Io.Writer = undefined, tls_client: ?tls.Client = null, - ca_bundle: std.crypto.Certificate.Bundle = .empty, - ca_lock: Io.RwLock = .init, gpa: Allocator, /// (#401) The peer is wedged or gone — tear down with a plain FIN instead /// of deinit's courtesy close frame, which is another blocking write on the @@ -123,28 +122,26 @@ pub const WsClient = struct { self.* = .{ .io = io, .stream = stream, .rd = undefined, .wr = undefined, .gpa = gpa }; self.rd = net.Stream.Reader.init(stream, io, &self.sock_rbuf); self.wr = net.Stream.Writer.init(stream, io, &self.sock_wbuf); - // From here the client owns the socket (and, for wss, the CA bundle): - // release both if the TLS or upgrade handshake fails, or every failed - // dial leaks an fd — which #401's reconnect ladder now retries into. - errdefer { - self.ca_bundle.deinit(gpa); - self.stream.close(io); - } + // From here the client owns the socket: release it if the TLS or + // upgrade handshake fails, or every failed dial leaks an fd — which + // #401's reconnect ladder now retries into. The CA bundle is the + // process-warmed one (http_warm); do not deinit it here. + errdefer self.stream.close(io); if (u.tls) { var entropy: [tls.Client.Options.entropy_len]u8 = undefined; io.random(&entropy); - if (!insecure) self.ca_bundle.rescan(gpa, io, Io.Clock.real.now(io)) catch |e| { + if (!insecure) http_warm.ensureProcessCa(io) catch |e| { dbg("ca rescan failed: {s}", .{@errorName(e)}); return error.HandshakeFailed; }; self.tls_client = tls.Client.init(&self.rd.interface, &self.wr.interface, .{ .host = if (insecure) .no_verification else .{ .explicit = u.host }, .ca = if (insecure) .no_verification else .{ .bundle = .{ - .gpa = gpa, + .gpa = std.heap.page_allocator, .io = io, - .lock = &self.ca_lock, - .bundle = &self.ca_bundle, + .lock = http_warm.processCaLock(), + .bundle = http_warm.processCa(), } }, .write_buffer = &self.tls_wbuf, .read_buffer = &self.tls_rbuf, @@ -167,7 +164,6 @@ pub const WsClient = struct { pub fn deinit(self: *WsClient, gpa: Allocator) void { if (!self.dead) self.sendFrame(.close, "") catch {}; // (#401) see `dead` - self.ca_bundle.deinit(gpa); self.stream.close(self.io); gpa.destroy(self); } diff --git a/src/zai_wire.zig b/src/zai_wire.zig index 3b911a09..7731747b 100644 --- a/src/zai_wire.zig +++ b/src/zai_wire.zig @@ -33,14 +33,29 @@ pub fn vercelEffort(requested: []const u8) []const u8 { return "medium"; } +/// DeepSeek V4 (native or via codegraff/fireworks): thinking is ON at +/// high unless `thinking.type` is `disabled`. `reasoning_effort: low` +/// still emits `reasoning_content` (ADR 0054). +pub fn isDeepseekFamily(provider_id: []const u8, model: []const u8) bool { + if (std.mem.eql(u8, provider_id, "deepseek")) return true; + return std.mem.indexOf(u8, model, "deepseek") != null; +} + /// OpenAI-chat extras after `prompt_cache_key`: Z.AI thinking, Vercel -/// `reasoning.effort`, then the shared `reasoning_effort` hint. -pub fn writeChatExtras(s: *std.json.Stringify, provider_id: []const u8, send_effort: bool, requested: []const u8) !void { +/// `reasoning.effort`, DeepSeek thinking on/off, then `reasoning_effort`. +pub fn writeChatExtras(s: *std.json.Stringify, provider_id: []const u8, model: []const u8, send_effort: bool, requested: []const u8) !void { const is_zai = std.mem.eql(u8, provider_id, "zai"); const is_vercel = std.mem.eql(u8, provider_id, "vercel"); if (is_zai) { try s.objectField("thinking"); try s.print("{s}", .{"{\"type\":\"enabled\",\"clear_thinking\":false}"}); + } else if (isDeepseekFamily(provider_id, model) and send_effort) { + // Official off switch. GLM rejects type=disabled; do not send it there. + try s.objectField("thinking"); + try s.print("{s}", .{if (std.mem.eql(u8, requested, "low")) + "{\"type\":\"disabled\"}" + else + "{\"type\":\"enabled\"}"}); } if (is_vercel and send_effort) { try s.objectField("reasoning"); @@ -56,6 +71,14 @@ pub fn writeChatExtras(s: *std.json.Stringify, provider_id: []const u8, send_eff } } +test "DeepSeek family is native id or a deepseek model name" { + try std.testing.expect(isDeepseekFamily("deepseek", "deepseek-v4-pro")); + try std.testing.expect(isDeepseekFamily("codegraff", "deepseek-v4-flash")); + try std.testing.expect(isDeepseekFamily("fireworks", "accounts/fireworks/models/deepseek-v4-flash")); + try std.testing.expect(!isDeepseekFamily("codegraff", "glm-5.3-flash")); + try std.testing.expect(!isDeepseekFamily("zai", "glm-5.3")); +} + test "Z.AI maps graff efforts onto low|high|max" { try std.testing.expectEqualStrings("low", reasoningEffort("low")); try std.testing.expectEqualStrings("high", reasoningEffort("medium"));