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/docs/adr/0048-resume-branches-have-independent-durable-identity.md b/docs/adr/0048-resume-branches-have-independent-durable-identity.md new file mode 100644 index 00000000..8e759792 --- /dev/null +++ b/docs/adr/0048-resume-branches-have-independent-durable-identity.md @@ -0,0 +1,43 @@ +# 0048. 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/README.md b/docs/adr/README.md index c769cac1..758debe3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,6 +58,7 @@ 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-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. | ## When to write one 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..2a39c18f 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -14,9 +14,8 @@ const ReasoningEffort = main_mod.ReasoningEffort; const ws = @import("ws.zig"); // codex Responses WS transport (delta continuation held across a turn) const mcp = @import("mcp.zig"); const approvals_mod = @import("approvals.zig"); -/// The shared approval state, re-exported: a module that only passes one -/// through (session_run's startup helpers) can name the type off the Agent -/// that owns it instead of importing approvals.zig itself (#429). +/// Re-exported so startup helpers can name the Agent-owned approval state +/// without importing approvals.zig themselves (#429). pub const Approvals = approvals_mod.Approvals; const trace = @import("trace.zig"); const tools_mod = @import("tools.zig"); @@ -167,6 +166,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, 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..a7a177e9 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -274,6 +274,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..fbc4b14a 100644 --- a/src/command_catalog.zig +++ b/src/command_catalog.zig @@ -65,7 +65,7 @@ pub const commands = [_]Item{ .{ .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/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/main.zig b/src/main.zig index 158f7966..14a6c745 100644 --- a/src/main.zig +++ b/src/main.zig @@ -482,9 +482,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 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..c10bc2fb 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 @@ -215,6 +259,7 @@ pub fn replTurnCb(ctx_ptr: ?*anyopaque, gpa: Allocator, history: []const repl.Tu }; 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/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..764f497b 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| { @@ -109,12 +115,26 @@ pub fn runReplCommand(gpa: Allocator, io: Io, environ_map: anytype, root: *agent models_buf.appendSlice(mi.name) catch {}; } try repl.runScripted(gpa, io, environ_map, in, out, &repl_ctx, repl_glue.replTurnCb, repl_glue.replModelCb, repl_glue.replCancelCb, root.provider.model, models_buf.items); + root.messages = try convo.cloneInto(root.arena); // 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); 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..1fd6f255 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"); @@ -241,6 +242,7 @@ test { _ = repl; _ = repl_markdown; _ = repl_parser; + _ = tui_session; _ = router_config; _ = subagent_selection; _ = subagent_pin_tests; 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); +}