From e21372394232ad87a3a7b994f1cef471ada83229 Mon Sep 17 00:00:00 2001 From: yxlyx <85774423+yxlyx@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:16:10 +0800 Subject: [PATCH] fix: add isolated session branching Concurrent resumes previously shared one durable session identity, so autosaves and shutdown writes could overwrite another continuation. Add clone-on-write resume targets across line, scripted, ACP, and fullscreen TUI paths, claim destination names atomically, and keep provider-native history plus session metadata attached to the selected branch.\n\nRecord the identity contract in ADR 0042 and cover source immutability, branch isolation, ownership stability, cache identity, and destination races with an offline process regression.\n\nCo-Authored-By: Codegraff --- TUI/catalog.zig | 1 + TUI/dispatch.zig | 18 +- TUI/engine.zig | 28 ++ TUI/resume.zig | 70 +++++ TUI/root.zig | 9 + TUI/run.zig | 15 + ...nches-have-independent-durable-identity.md | 43 +++ docs/adr/README.md | 1 + scripts/test-session-branching.py | 295 ++++++++++++++++++ src/agent.zig | 1 + src/args.zig | 8 +- src/cli.zig | 1 + src/command_catalog.zig | 2 +- src/commands_misc.zig | 63 +--- src/commands_resume.zig | 81 +++++ src/http_headers.zig | 9 + src/main.zig | 5 +- src/repl_convo.zig | 19 ++ src/repl_turn.zig | 49 ++- src/session.zig | 8 + src/session_branch.zig | 75 +++++ src/session_index.zig | 22 +- src/session_run.zig | 50 ++- src/test_hooks.zig | 2 + src/tui_launch.zig | 40 +++ src/tui_session.zig | 123 ++++++++ 26 files changed, 951 insertions(+), 87 deletions(-) create mode 100644 TUI/resume.zig create mode 100644 docs/adr/0042-resume-branches-have-independent-durable-identity.md create mode 100644 scripts/test-session-branching.py create mode 100644 src/commands_resume.zig create mode 100644 src/session_branch.zig create mode 100644 src/tui_session.zig 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 503c73f6..ca45e023 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"); /// Restore the terminal BEFORE std prints a panic, or the alt-screen exit in /// the restore sequence erases the message and the stack trace (#535). @@ -55,6 +63,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 5f19f0c6..5a24f295 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; var raw = tty.enterRaw() orelse return error.NotATty; @@ -437,6 +451,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/0042-resume-branches-have-independent-durable-identity.md b/docs/adr/0042-resume-branches-have-independent-durable-identity.md new file mode 100644 index 00000000..c4be74f5 --- /dev/null +++ b/docs/adr/0042-resume-branches-have-independent-durable-identity.md @@ -0,0 +1,43 @@ +# 0042. 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 e76f1c51..783f6a71 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -52,6 +52,7 @@ record only when you need the evidence or the edge cases. | [0039](0039-local-tools-are-project-scripts.md) | Agent-authored local tools are project scripts under `.graff/tools/`; skills stay instructions. Runtime catalog extras, not `schema.effectiveRootSpecs`. | | [0040](0040-codedb-stays-when-licensed.md) | Ordinary reads use native `codedb` / `read_file`; codedb-pro is extra search, not the default reader. | | [0041](0041-tui-is-an-acp-client.md) | The fullscreen TUI is an in-process ACP client: session/prompt in, session/update thought/tool/text out. No child `graff acp`. | +| [0042](0042-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 7f3ad6ac..059012af 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, 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 8d3ad8c4..558e8873 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -267,6 +267,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 e6f5c174..4162923f 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 bb7bbf17..4f68e028 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"); @@ -86,6 +87,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, @@ -102,6 +106,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| { @@ -110,9 +116,23 @@ 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); 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), streaming stays quiet, and the gate @@ -308,7 +328,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 @@ -371,13 +392,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 }); } } @@ -389,12 +406,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 @@ -403,11 +416,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 15212124..7fb12292 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); +}