diff --git a/.gitignore b/.gitignore index e7d386bfb..486579b90 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,7 @@ benchmarks/baseline.json .vibe/ .windsurf/ .zencoder/ + +# Python bytecode caches from scripts/ +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 37f120eb0..52f411cfc 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,30 @@ fx session resume last fx session resume --id ``` +Inside the interactive shell, use `/resume` to open the session picker, `/resume last` for the latest workspace session, or `/resume ` for an exact session. + +`fx session ` prints the saved conversation with a `[turn N]` label above every turn. Those labels are the boundaries the branch and undo commands take: + +```bash +fx session fork --at 7 +fx session rewind --by 2 +``` + +`fork` copies the first 7 turns into a brand-new session and prints the new ID to resume. The source session is left exactly as it was. `rewind` drops the last 2 turns from the session in place, keeping its ID. Both accept `--id ` and `--json`. + +Rewound turns are not erased. The rewind is recorded as a new revision in the session's event log, and the files those turns referenced stay on disk. + +The interactive shell has the same two operations for the session it is already in: + +``` +/fork 7 +/rewind 2 +``` + +`/fork` branches the whole session at its current point; `/fork 7` branches at turn 7. Both forms name the source ID and the new one and leave you in the branch, while the source session keeps every turn it had. Bare `/rewind` opens a turn picker that restores to before the selected prompt and puts that prompt back in the composer. `/rewind 2` asks first: the message says how many turns it will drop and how many remain, and a second identical `/rewind 2` carries it out. Any other command in between cancels it. + +Neither command reverts file edits, commands, commits, or API calls. They change the conversation only. + Each interactive session names its terminal tab. The title prefers the session name, falls back to the workspace name, and keeps the active model as secondary context. Renaming or resuming a session updates the tab, and exiting clears the fx-owned title. Noninteractive commands do not emit terminal-title controls. Run `/feedback` to open the feedback form at `fx.sh/feedback`. It does not create a diagnostic or change the clipboard. diff --git a/scripts/pgso/corpus.json b/scripts/pgso/corpus.json index cc37f1c68..3e3250faf 100644 --- a/scripts/pgso/corpus.json +++ b/scripts/pgso/corpus.json @@ -113,6 +113,8 @@ ], "verification_scenarios": [ {"name": "verify-auto-mode-reliability", "argv": ["bun", "test", "--max-concurrency", "1", "./auto-mode-reliability.test.ts"], "test_file": "auto-mode-reliability.test.ts"}, + {"name": "verify-session-fork", "argv": ["bun", "test", "--max-concurrency", "1", "./session-fork.test.ts"], "test_file": "session-fork.test.ts", "requires_tmux": false}, + {"name": "verify-tui-session-fork", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-session-fork.test.ts"], "test_file": "tui-session-fork.test.ts", "requires_tmux": true}, {"name": "verify-oauth-keychain-migration", "argv": ["bun", "test", "--max-concurrency", "1", "./oauth-keychain-migration.test.ts"], "test_file": "oauth-keychain-migration.test.ts", "allow_keychain": true}, {"name": "verify-tui-auth-source-selection", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-auth-source-selection.test.ts"], "test_file": "tui-auth-source-selection.test.ts"}, {"name": "verify-tui-composer-edit-contracts", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-composer-edit-contracts.test.ts"], "test_file": "tui-composer-edit-contracts.test.ts"}, diff --git a/src/builtins/commands.zig b/src/builtins/commands.zig index adcdaa9dd..24c25f40b 100644 --- a/src/builtins/commands.zig +++ b/src/builtins/commands.zig @@ -193,14 +193,16 @@ pub const top_level_specs = [_]TopLevelSpec{ .{ .kind = .session, .token = "session", - .usage = "session |--id [--json] | session resume [last|] | session resume --id | session migrate |--id [--allow-large] [--json] | session recover |--id [--json]", - .summary = "Inspect, resume, migrate, or recover saved sessions", + .usage = "session |--id [--json] | session resume [last|] | session resume --id | session migrate |--id [--allow-large] [--json] | session recover |--id [--json] | session fork |--id --at [--json] | session rewind |--id --by [--json]", + .summary = "Inspect, resume, migrate, recover, fork, or rewind saved sessions", .options = &.{ .{ .flag = "last", .description = "Inspect the current workspace session" }, .{ .flag = "--id ", .description = "Inspect a saved session by exact id" }, .{ .flag = "resume [last|]", .description = "Resume the latest workspace session or a session by id" }, .{ .flag = "migrate ", .description = "Migrate a saved session to the current format" }, .{ .flag = "recover ", .description = "Copy a recoverable corrupt session into a new session" }, + .{ .flag = "fork --at ", .description = "Branch a session into a new session holding its first turns" }, + .{ .flag = "rewind --by ", .description = "Drop the last turns from a session in place" }, .{ .flag = "--allow-large", .description = "Permit migrating an oversized session" }, json_option, }, @@ -312,6 +314,8 @@ pub const top_level_help_groups = [_]TopLevelHelpGroup{ .{ .usage = "session resume [last|id]", .summary = "Resume the latest workspace session or a session by id" }, .{ .usage = "session migrate ", .summary = "Migrate a saved session to the current format" }, .{ .usage = "session recover ", .summary = "Copy a recoverable corrupt session" }, + .{ .usage = "session fork --at ", .summary = "Branch a session at a turn into a new session" }, + .{ .usage = "session rewind --by ", .summary = "Drop the last turns from a session in place" }, } }, .{ .entries = &.{ .{ .kind = .login, .usage = "login [vercel|codex|grok]" }, @@ -435,9 +439,11 @@ pub const slash_specs = [_]SlashSpec{ .{ .kind = .clear_screen, .command = "/clear", .help_entry = "/clear", .completion_description = "start a fresh session and keep background processes", .presentation_category = .general, .show_in_welcome = true }, .{ .kind = .new_session, .command = "/new", .help_entry = "/new", .completion_description = "start a fresh session", .presentation_category = .session, .show_in_welcome = true }, .{ .kind = .reset_session, .command = "/reset", .help_entry = "/reset", .completion_description = "reset the current session context", .presentation_category = .session }, - .{ .kind = .resume_session, .command = "/resume", .help_entry = "/resume", .completion_description = "resume a saved session", .presentation_category = .session }, + .{ .kind = .resume_session, .command = "/resume", .help_entry = "/resume [last|]", .completion_description = "resume a saved session", .presentation_category = .session, .has_args = true, .accepts_payload = true }, .{ .kind = .continue_recovery, .command = "/continue", .help_entry = "/continue", .completion_description = "continue a paused model response", .presentation_category = .session, .requires_prompt_credential = true }, .{ .kind = .rename_session, .command = "/rename", .help_entry = "/rename ", .completion_description = "rename the current session", .presentation_category = .session, .has_args = true, .accepts_payload = true }, + .{ .kind = .fork_session, .command = "/fork", .help_entry = "/fork [turn]", .completion_description = "branch this session at a turn into a new one", .presentation_category = .session, .has_args = true, .accepts_payload = true }, + .{ .kind = .rewind_session, .command = "/rewind", .help_entry = "/rewind [count]", .completion_description = "drop the last turns from this session", .presentation_category = .session, .has_args = true, .accepts_payload = true }, .{ .kind = .login, .command = "/login", .help_entry = "/login", .completion_description = "choose Vercel or Codex sign-in", .presentation_category = .account }, .{ .kind = .logout, .command = "/logout", .help_entry = "/logout [vercel|codex|grok]", .completion_description = "sign out of a provider session", .presentation_category = .account, .has_args = true, .accepts_payload = true }, .{ .kind = .setup, .command = "/setup", .help_entry = "/setup", .completion_description = "manage accounts and AI Gateway access", .presentation_category = .account }, @@ -538,6 +544,8 @@ test "built-in slash commands register exact active order" { "/resume", "/continue", "/rename", + "/fork", + "/rewind", "/login", "/logout", "/setup", diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig index 5280d7af0..c2d2999cf 100644 --- a/src/core/app/app_commands.zig +++ b/src/core/app/app_commands.zig @@ -34,6 +34,7 @@ const skill_runtime = @import("../skills/skill_runtime.zig"); const text_utils = @import("../shared/text_utils.zig"); const tool_presentation = @import("../tooling/tool_presentation.zig"); const session_commands = @import("../session/session_commands.zig"); +const session_store_paths = @import("../session/session_store_paths.zig"); const usage_recovery = @import("../session/usage_recovery.zig"); const usage_dashboard_runtime = @import("usage_dashboard_runtime.zig"); const usage_report = @import("../session/usage_report.zig"); @@ -339,8 +340,15 @@ fn requestResumeExit(app: anytype) void { pub fn Handlers(comptime App: type) type { return struct { pub fn route(app: *App, cmd: []const u8) !void { + const parsed = command_router.parse(app.slashRegistry(), cmd); + if (comptime @hasField(App, "session_persistence")) { + switch (parsed) { + .rewind_session => {}, + else => app_session_runtime.Runtime(App).disarmRewind(app), + } + } const handlers = commandHandlers(app); - try command_router.route(app.slashRegistry(), &handlers, cmd); + try command_router.dispatch(&handlers, parsed, cmd); } pub fn commandHandlers(app: *App) command_router.CommandHandlers { @@ -382,6 +390,8 @@ pub fn Handlers(comptime App: type) type { .toggle_fast = commandToggleFast, .handle_statusline = commandHandleStatusline, .rename_session = commandRenameSession, + .fork_session = commandForkSession, + .rewind_session = commandRewindSession, .handle_notifications = commandHandleNotifications, .handle_workspace = commandHandleWorkspace, .show_version = commandShowVersion, @@ -644,7 +654,7 @@ pub fn Handlers(comptime App: type) type { try app.newSession(); } - fn commandResumeSession(ctx: *anyopaque) !void { + fn commandResumeSession(ctx: *anyopaque, rest: []const u8) !void { const app: *App = @ptrCast(@alignCast(ctx)); if (comptime !runtime_profile.allows(App, .durable_sessions)) { try app.writeDomainNotice(.{ @@ -654,7 +664,50 @@ pub fn Handlers(comptime App: type) type { }, true); return; } - try app_session_runtime.Runtime(App).openSessionPicker(app); + const target = std.mem.trim(u8, rest, " \t\r\n"); + if (target.len == 0) { + try app_session_runtime.Runtime(App).openSessionPicker(app); + return; + } + if (app.stream.active) { + try app.writeDomainNotice(.{ + .topic = "session", + .tone = .neutral, + .body = "resume is unavailable until the response finishes", + }, true); + return; + } + if (!std.mem.eql(u8, target, "last")) { + session_store_paths.validateSessionId(target) catch { + try app.writeDomainNotice(.{ + .topic = "session", + .tone = .@"error", + .body = "invalid session ID", + }, true); + return; + }; + } + const resume_target: @import("../session/session_store.zig").ResumeTarget = + if (std.mem.eql(u8, target, "last")) .last else .{ .id = target }; + const resumed = app_session_runtime.Runtime(App).resumeLiveSession(app, resume_target) catch |err| { + const body: []const u8 = switch (err) { + error.SessionNotFound => "no saved session with that ID", + error.SessionBusy => "This session is open in another fx. Close it there, then try again.", + error.SessionAuthorityBoundaryUnavailable, + error.SessionCommitBoundaryUnavailable, + => "This session is being updated. Wait a moment, then try again.", + else => "Unable to resume this session.", + }; + try app.writeDomainNotice(.{ .topic = "session", .tone = .@"error", .body = body }, true); + return; + }; + if (!resumed) { + try app.writeDomainNotice(.{ + .topic = "session", + .tone = .neutral, + .body = "this session is already active", + }, true); + } } fn commandContinueRecovery(ctx: *anyopaque) !void { @@ -683,6 +736,16 @@ pub fn Handlers(comptime App: type) type { try handleRenameCommand(app, rest); } + fn commandForkSession(ctx: *anyopaque, rest: []const u8) !void { + const app: *App = @ptrCast(@alignCast(ctx)); + try handleForkCommand(app, rest); + } + + fn commandRewindSession(ctx: *anyopaque, rest: []const u8) !void { + const app: *App = @ptrCast(@alignCast(ctx)); + try handleRewindCommand(app, rest); + } + fn commandShowHelp(ctx: *anyopaque) !void { const app: *App = @ptrCast(@alignCast(ctx)); if (comptime @hasField(App, "skills")) app.skills.closeMenu(); @@ -3431,6 +3494,258 @@ fn handleRenameCommand(app: anytype, rest: []const u8) !void { try app.writeDomainNotice(.{ .topic = "session", .tone = .neutral, .body = msg }, true); } +fn turnPlural(count: usize) []const u8 { + return if (count == 1) "" else "s"; +} + +fn parsedTurnCount(rest: []const u8) ?usize { + const trimmed = std.mem.trim(u8, rest, " \t"); + if (trimmed.len == 0) return null; + const value = std.fmt.parseInt(usize, trimmed, 10) catch return null; + return if (value == 0) null else value; +} + +/// Points at the command that lists the turn numbers, naming the live session +/// so the reader can paste the line as written. +fn writeTurnArgumentUsage(app: anytype, usage: []const u8) !void { + const App = @TypeOf(app.*); + const id = app_session_runtime.Runtime(App).activeSessionId(app) orelse "<id>"; + const body = try std.fmt.allocPrint( + app.alloc, + "Use: {s}. Run `fx session {s}` to see turn numbers.", + .{ usage, id }, + ); + defer app.alloc.free(body); + try app.writeDomainNotice( + .{ .topic = "session", .tone = .@"error", .body = body }, + true, + ); +} + +fn handleForkCommand(app: anytype, rest: []const u8) !void { + const App = @TypeOf(app.*); + const SessionRuntime = app_session_runtime.Runtime(App); + + const trimmed = std.mem.trim(u8, rest, " \t\r\n"); + const bare = trimmed.len == 0; + const at_turn = if (bare) + app.session.historyLen() + else + parsedTurnCount(trimmed) orelse { + try writeTurnArgumentUsage(app, "/fork <turn>"); + return; + }; + if (bare and at_turn == 0) { + try app.writeDomainNotice(.{ + .topic = "session", + .tone = .neutral, + .body = "there are no turns to fork", + }, true); + return; + } + + var outcome = try SessionRuntime.forkLiveSession(app, at_turn); + defer outcome.deinit(app.alloc); + + switch (outcome) { + .unavailable_during_stream => try app.writeDomainNotice(.{ + .topic = "session", + .tone = .neutral, + .body = "fork is unavailable until the response finishes", + }, true), + .unavailable => try app.writeDomainNotice(.{ + .topic = "session", + .tone = .@"error", + .body = "no saved session to fork", + }, true), + .out_of_range => |history_len| { + const body = try std.fmt.allocPrint( + app.alloc, + "turn {d} is out of range; session has {d} turn{s}", + .{ at_turn, history_len, turnPlural(history_len) }, + ); + defer app.alloc.free(body); + try app.writeDomainNotice( + .{ .topic = "session", .tone = .@"error", .body = body }, + true, + ); + }, + .branched => |branch| { + var out: std.Io.Writer.Allocating = .init(app.alloc); + defer out.deinit(); + if (bare) { + try out.writer.print( + "Forked {s} into {s}. You are now in the branch; " ++ + "resume the original with /resume {s} or fx --resume {s}.", + .{ branch.source_id, branch.forked_id, branch.source_id, branch.source_id }, + ); + } else { + try out.writer.print( + "Forked {s} at turn {d} into {s}. " ++ + "You are now in the branch; {s} is unchanged.", + .{ + branch.source_id, + branch.retained_turns, + branch.forked_id, + branch.source_id, + }, + ); + } + if (branch.unverified_artifacts) { + try out.writer.writeAll( + " Some legacy command artifacts could not be authenticated.", + ); + } + try app.writeDomainNotice( + .{ .topic = "session", .tone = .neutral, .body = out.written() }, + true, + ); + app.shell.render_requests.request(.footer); + }, + .failed => |failure| try writeForkFailureNotice(app, failure), + } +} + +/// A failed fork still names every id involved, because the reader has to know +/// whether a branch exists and which session this shell ended up on. +fn writeForkFailureNotice( + app: anytype, + failure: app_session_runtime.Runtime(@TypeOf(app.*)).ForkOutcome.Failure, +) !void { + var out: std.Io.Writer.Allocating = .init(app.alloc); + defer out.deinit(); + + if (failure.forked_id) |forked_id| { + try out.writer.print( + "Forked {s} into {s} but could not open it ({s}); run `fx --resume {s}`.", + .{ + failure.source_id, + forked_id, + @errorName(failure.problem), + forked_id, + }, + ); + } else { + try out.writer.print( + "Could not fork {s} ({s}).", + .{ failure.source_id, @errorName(failure.problem) }, + ); + } + + switch (failure.landing) { + .source => try out.writer.print( + " This shell is still on {s}.", + .{failure.source_id}, + ), + .fresh_session => try out.writer.writeAll( + " This shell is on a new empty session.", + ), + .no_session => try out.writer.writeAll( + " This shell has no session; run /resume to open one.", + ), + } + + try app.writeDomainNotice( + .{ .topic = "session", .tone = .warning, .body = out.written() }, + true, + ); + app.shell.render_requests.request(.footer); +} + +fn handleRewindCommand(app: anytype, rest: []const u8) !void { + const App = @TypeOf(app.*); + const SessionRuntime = app_session_runtime.Runtime(App); + + const trimmed = std.mem.trim(u8, rest, " \t\r\n"); + if (trimmed.len == 0) { + if (app.stream.active) { + try app.writeDomainNotice(.{ + .topic = "session", + .tone = .neutral, + .body = "rewind is unavailable until the response finishes", + }, true); + } else if (app.session.historyLen() == 0) { + try app.writeDomainNotice(.{ + .topic = "session", + .tone = .neutral, + .body = "there are no turns to rewind", + }, true); + } else { + _ = try SessionRuntime.openTurnPicker(app); + } + return; + } + const requested = parsedTurnCount(trimmed) orelse { + try writeTurnArgumentUsage(app, "/rewind <count>"); + return; + }; + + switch (try SessionRuntime.rewindLiveSession(app, requested)) { + .unavailable_during_stream => try app.writeDomainNotice(.{ + .topic = "session", + .tone = .neutral, + .body = "rewind is unavailable until the response finishes", + }, true), + .out_of_range => |history_len| { + const body = try std.fmt.allocPrint( + app.alloc, + "cannot rewind {d} turn{s}; session has {d} turn{s}", + .{ + requested, + turnPlural(requested), + history_len, + turnPlural(history_len), + }, + ); + defer app.alloc.free(body); + try app.writeDomainNotice( + .{ .topic = "session", .tone = .@"error", .body = body }, + true, + ); + }, + .confirm => |target| { + var count_buf: [40]u8 = undefined; + const dropped = if (target.removedTurnCount() == 1) + "the last turn" + else + try std.fmt.bufPrint( + &count_buf, + "the last {d} turns", + .{target.removedTurnCount()}, + ); + const body = try std.fmt.allocPrint( + app.alloc, + "/rewind {d} drops {s} and leaves {d}. " ++ + "Run /rewind {d} again to confirm. File changes are not reverted.", + .{ requested, dropped, target.retained_turns, requested }, + ); + defer app.alloc.free(body); + try app.writeDomainNotice( + .{ .topic = "session", .tone = .warning, .body = body }, + true, + ); + }, + .rewound => |target| { + const body = try std.fmt.allocPrint( + app.alloc, + "Rewound {d} turn{s}; {d} turn{s} left. File changes were not reverted.", + .{ + target.removedTurnCount(), + turnPlural(target.removedTurnCount()), + target.retained_turns, + turnPlural(target.retained_turns), + }, + ); + defer app.alloc.free(body); + try app.writeDomainNotice( + .{ .topic = "session", .tone = .neutral, .body = body }, + true, + ); + app.shell.render_requests.request(.footer); + }, + } +} + const StatuslineFeedback = enum { announce, silent }; fn parseStatuslineItem(raw: []const u8) ?config_runtime.StatuslineItem { diff --git a/src/core/app/app_input_runtime.zig b/src/core/app/app_input_runtime.zig index 04b930a6f..89e191b71 100644 --- a/src/core/app/app_input_runtime.zig +++ b/src/core/app/app_input_runtime.zig @@ -2327,12 +2327,16 @@ pub fn Runtime(comptime App: type) type { } const CompactCommandMenuKind = enum { + turn_picker, statusline, usage, workspace, }; fn activeCompactCommandMenu(app: *App) ?CompactCommandMenuKind { + if (comptime @hasField(App, "session_persistence") and @hasField(App, "session")) { + if (app.session_persistence.turn_picker != null) return .turn_picker; + } if (app.input_runtime.statusline_menu.active) return .statusline; if (app.input_runtime.usage_menu.active) return .usage; if (app.input_runtime.workspace_menu.active) return .workspace; @@ -2534,6 +2538,12 @@ pub fn Runtime(comptime App: type) type { menu: CompactCommandMenuKind, max_input_len: usize, ) !void { + if (menu == .turn_picker) { + if (comptime @hasField(App, "session")) { + _ = try app_session_runtime.Runtime(App).applyTurnPicker(app); + } + return; + } if (menu == .workspace) { try prepareWorkspaceMenuCommand(app, max_input_len); return; @@ -2548,7 +2558,7 @@ pub fn Runtime(comptime App: type) type { const snapshot = app_commands.settingsCatalogSnapshot(app); const change = switch (menu) { .statusline => app.input_runtime.statusline_menu.selectedChange(snapshot), - .usage, .workspace => unreachable, + .turn_picker, .usage, .workspace => unreachable, } orelse return; if (comptime @hasDecl(App, "notificationPreferences")) { try app_commands.applySettingsCatalogMenuChange(app, change); @@ -2589,6 +2599,10 @@ pub fn Runtime(comptime App: type) type { fn moveCompactCommandMenu(app: *App, menu: CompactCommandMenuKind, delta: i32) bool { return switch (menu) { + .turn_picker => if (comptime @hasField(App, "session")) + app_session_runtime.Runtime(App).moveTurnPicker(app, delta, app_session_runtime.TurnPicker.window_rows) + else + false, .statusline => app.input_runtime.statusline_menu.move(delta), .usage => app.input_runtime.usage_menu.moveModel( delta, @@ -2654,7 +2668,7 @@ pub fn Runtime(comptime App: type) type { const delta: i32 = if (resolved == .cursor_left) -1 else 1; const change = switch (menu) { .statusline => app.input_runtime.statusline_menu.changeSelectedOption(&snapshot, delta), - .usage, .workspace => unreachable, + .turn_picker, .usage, .workspace => unreachable, } orelse return; if (comptime @hasDecl(App, "notificationPreferences")) { try app_commands.applySettingsCatalogMenuChange(app, change); @@ -2821,7 +2835,7 @@ pub fn Runtime(comptime App: type) type { if (comptime @hasField(App, "stream")) { if (app.stream.active) return false; } - if (comptime @hasField(App, "session_persistence")) { + if (comptime @hasField(App, "session_persistence") and @hasField(App, "session")) { if (app.session_persistence.session_picker.active) return true; } if (comptime @hasField(App, "auth")) { @@ -3185,6 +3199,12 @@ pub fn Runtime(comptime App: type) type { } fn cancelCompactCommandMenu(app: *App) bool { + if (comptime @hasField(App, "session_persistence")) { + if (app.session_persistence.turn_picker != null) { + _ = app_session_runtime.Runtime(App).cancelTurnPicker(app) catch {}; + return true; + } + } if (app.input_runtime.statusline_menu.active) { app.input_runtime.statusline_menu.close(); return true; diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index 78566da69..f056fb0e1 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -757,6 +757,17 @@ pub fn Runtime(comptime App: type) type { .now_ms = now_ms, .selection_failure = app.session_persistence.session_picker.selection_failure, } else .{}, + .turn_picker = if (comptime @hasField(App, "session_persistence") and @hasField(App, "session")) blk: { + const picker = app.session_persistence.turn_picker orelse break :blk .{}; + break :blk .{ + .active = true, + .history = app.session.history.items, + .history_len = picker.history_len, + .cursor = picker.cursor, + .window_start = picker.window_start, + .visible_rows = app_session_runtime.TurnPicker.window_rows, + }; + } else .{}, .statusline_menu = render_input.statuslineMenuProjection( &app.input_runtime.statusline_menu, settings_snapshot, diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 39a19e729..90c11652e 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -60,6 +60,195 @@ const BackgroundSessionPolicy = enum { stop_forget, }; +/// `/fork <turn>` and `/rewind <count>` both accept 1 through the live history +/// length and differ only in which end they count from. Returns null when the +/// argument falls outside it. +fn checkedTurnArgument(history_len: usize, requested: usize) ?usize { + if (requested == 0 or requested > history_len) return null; + return requested; +} + +test "turn arguments are bounded by the live history length" { + try std.testing.expectEqual(@as(?usize, null), checkedTurnArgument(0, 1)); + try std.testing.expectEqual(@as(?usize, null), checkedTurnArgument(3, 0)); + try std.testing.expectEqual(@as(?usize, null), checkedTurnArgument(3, 4)); + try std.testing.expectEqual(@as(?usize, 1), checkedTurnArgument(3, 1)); + try std.testing.expectEqual(@as(?usize, 3), checkedTurnArgument(3, 3)); +} + +/// The exact rewind a confirmation prompt described: how long the history was +/// when it was previewed, and how many turns would survive. +pub const RewindTarget = struct { + history_len: usize, + retained_turns: usize, + + pub fn removedTurnCount(self: RewindTarget) usize { + return self.history_len - self.retained_turns; + } +}; + +const RewindRequest = enum { + confirm, + execute, +}; + +/// `/rewind` destroys turns, so it runs only when the identical request is +/// repeated. Arming on the whole target rather than the raw count means a turn +/// arriving between the two requests re-arms instead of silently firing a +/// rewind the user never previewed. +const RewindGate = union(enum) { + idle, + armed: RewindTarget, + + pub fn request(self: *RewindGate, target: RewindTarget) RewindRequest { + switch (self.*) { + .armed => |pending| if (std.meta.eql(pending, target)) { + self.* = .idle; + return .execute; + }, + .idle => {}, + } + self.* = .{ .armed = target }; + return .confirm; + } + + pub fn disarm(self: *RewindGate) void { + self.* = .idle; + } +}; + +test "rewind gate executes only an identical repeated request" { + var gate: RewindGate = .idle; + const target = RewindTarget{ .history_len = 5, .retained_turns = 3 }; + + try std.testing.expectEqual(RewindRequest.confirm, gate.request(target)); + try std.testing.expectEqual(RewindRequest.execute, gate.request(target)); + try std.testing.expectEqual(RewindGate.idle, gate); + try std.testing.expectEqual(RewindRequest.confirm, gate.request(target)); +} + +test "rewind gate re-arms when the request changes" { + var gate: RewindGate = .idle; + + try std.testing.expectEqual( + RewindRequest.confirm, + gate.request(.{ .history_len = 5, .retained_turns = 3 }), + ); + try std.testing.expectEqual( + RewindRequest.confirm, + gate.request(.{ .history_len = 5, .retained_turns = 4 }), + ); + try std.testing.expectEqual( + RewindRequest.confirm, + gate.request(.{ .history_len = 6, .retained_turns = 4 }), + ); + try std.testing.expectEqual( + RewindRequest.execute, + gate.request(.{ .history_len = 6, .retained_turns = 4 }), + ); +} + +test "rewind gate disarms on an intervening command" { + var gate: RewindGate = .idle; + const target = RewindTarget{ .history_len = 5, .retained_turns = 3 }; + + try std.testing.expectEqual(RewindRequest.confirm, gate.request(target)); + gate.disarm(); + try std.testing.expectEqual(RewindRequest.confirm, gate.request(target)); +} + +test "rewind target reports the turns it drops" { + const target = RewindTarget{ .history_len = 7, .retained_turns = 4 }; + try std.testing.expectEqual(@as(usize, 3), target.removedTurnCount()); +} + +pub const TurnPicker = struct { + history_len: usize, + cursor: usize, + window_start: usize, + + pub const window_rows: usize = 8; + + pub const Effect = union(enum) { + no_change, + rewind: struct { + dropped_turns: usize, + retained_turns: usize, + }, + }; + + pub fn init(history_len: usize) TurnPicker { + return .{ + .history_len = history_len, + .cursor = history_len, + .window_start = (history_len + 1) -| window_rows, + }; + } + + pub fn move(self: *TurnPicker, delta: i32, visible_rows: usize) void { + const next = std.math.clamp( + @as(i64, @intCast(self.cursor)) + delta, + 0, + @as(i64, @intCast(self.history_len)), + ); + self.cursor = @intCast(next); + const rows = @max(visible_rows, 1); + if (self.cursor < self.window_start) self.window_start = self.cursor; + if (self.cursor >= self.window_start + rows) { + self.window_start = self.cursor + 1 - rows; + } + self.window_start = @min(self.window_start, (self.history_len + 1) -| rows); + } + + pub fn retainedTurns(self: TurnPicker) usize { + return self.cursor; + } + + pub fn effectLine(self: TurnPicker) Effect { + if (self.cursor == self.history_len) return .no_change; + return .{ .rewind = .{ + .dropped_turns = self.history_len - self.cursor, + .retained_turns = self.cursor, + } }; + } + + pub fn isCurrent(self: TurnPicker, history_len: usize) bool { + return self.history_len == history_len; + } +}; + +test "turn picker starts at current and moves with a clamped window" { + var picker = TurnPicker.init(6); + try std.testing.expectEqual(@as(usize, 6), picker.cursor); + try std.testing.expectEqual(@as(usize, 0), picker.window_start); + try std.testing.expectEqual(TurnPicker.Effect.no_change, picker.effectLine()); + + picker.move(-2, 3); + try std.testing.expectEqual(@as(usize, 4), picker.cursor); + try std.testing.expectEqual(@as(usize, 2), picker.window_start); + picker.move(-20, 3); + try std.testing.expectEqual(@as(usize, 0), picker.cursor); + try std.testing.expectEqual(@as(usize, 0), picker.window_start); + picker.move(20, 3); + try std.testing.expectEqual(@as(usize, 6), picker.cursor); + try std.testing.expectEqual(@as(usize, 4), picker.window_start); +} + +test "turn picker selection restores before the selected prompt" { + var picker = TurnPicker.init(5); + picker.move(-3, 5); + try std.testing.expectEqual(@as(usize, 2), picker.retainedTurns()); + try std.testing.expectEqual(TurnPicker.Effect{ + .rewind = .{ .dropped_turns = 3, .retained_turns = 2 }, + }, picker.effectLine()); +} + +test "turn picker detects history changes before selection" { + const picker = TurnPicker.init(4); + try std.testing.expect(picker.isCurrent(4)); + try std.testing.expect(!picker.isCurrent(5)); +} + const LiveSessionTransitionEvent = union(enum) { request: BackgroundSessionPolicy, settle, @@ -1090,12 +1279,14 @@ pub const Persistence = struct { resume_view_admission: ?session_store.ResumeViewAdmission = null, resume_handoff_intent: ResumeHandoffIntent = .none, pending_live_session_policy: ?BackgroundSessionPolicy = null, + rewind_gate: RewindGate = .idle, + turn_picker: ?TurnPicker = null, /// Fieldwise initialization avoids retaining undefined optional payloads /// in a static release-binary template. pub fn initInto(storage: *Persistence) void { comptime { - if (std.meta.fields(Persistence).len != 19) { + if (std.meta.fields(Persistence).len != 21) { @compileError("update Persistence.initInto for the changed field set"); } } @@ -1119,6 +1310,8 @@ pub const Persistence = struct { storage.resume_view_admission = null; storage.resume_handoff_intent = .none; storage.pending_live_session_policy = null; + storage.rewind_gate = .idle; + storage.turn_picker = null; } pub fn deinit(self: *Persistence, alloc: Allocator) void { @@ -1556,6 +1749,10 @@ pub fn Runtime(comptime App: type) type { } pub fn finishLiveSessionResume(app: *App) !void { + try resetTerminalForTranscriptReplacement(app); + } + + fn resetTerminalForTranscriptReplacement(app: *App) !void { try app.shell.requestTerminalReset(&app.metrics); app.shell.render_requests.request(.footer); } @@ -1852,13 +2049,39 @@ pub fn Runtime(comptime App: type) type { pub fn resumeSelectedSession(app: *App) !bool { const selected_id = app.session_persistence.session_picker.selectedId() orelse return false; + return resumeLiveSession(app, .{ .id = selected_id }); + } + + pub fn resumeLiveSession(app: *App, target: session_store.ResumeTarget) !bool { + if (target == .id and app.session_persistence.writable != null and + std.mem.eql(u8, target.id, app.session_persistence.writable.?.active_id)) + { + return false; + } + if (target == .last) { + // Resolve "last" once, through the same summary the CLI uses, so + // the id compared here is the id that gets opened below. + const store = app.session_persistence.store orelse + return error.SessionStoreUnavailable; + var latest = store.latestReadOnlyWorkspaceSummary(app.alloc) catch |err| switch (err) { + error.NoSavedSessions => return false, + else => return err, + }; + defer latest.deinit(app.alloc); + if (app.session_persistence.writable) |writable| { + if (std.mem.eql(u8, latest.id, writable.active_id)) return false; + } + const latest_id = try app.alloc.dupe(u8, latest.id); + defer app.alloc.free(latest_id); + return resumeLiveSession(app, .{ .id = latest_id }); + } const log_options = session_log.Options{ .session_lock_deadline_ms = 0, .commit_lock_deadline_ms = 0, }; var loaded = try loadResumeTargetForWrite( app, - .{ .id = selected_id }, + target, log_options, ); var loaded_owned = true; @@ -1957,6 +2180,32 @@ pub fn Runtime(comptime App: type) type { app.total_output_tokens = state.total_output_tokens; app.total_web_search_requests = 0; + const ResumeTranscriptExtras = struct { + display_title: []const u8, + notice: ResumeNotice, + recovery_state: session_codec.DurableSessionState, + + fn writeBefore(self: @This(), target: *App, sink: anytype) !void { + try writeResumeNotice(target, sink, self.display_title, self.notice); + } + + fn writeAfter(self: @This(), target: *App, sink: anytype) !void { + try writeRecoveryCheckpointToSink(target, sink, self.recovery_state); + } + }; + try rebuildTranscriptFromHistory(app, state.history, ResumeTranscriptExtras{ + .display_title = display_title, + .notice = notice, + .recovery_state = state, + }); + } + + fn rebuildTranscriptFromHistory( + app: *App, + history: []const types.HistoryTurn, + extras: anytype, + ) !void { + // Inline rendering only appends, so dropping turns requires the same retained-history redraw used by resume. if (comptime @hasDecl(App, "beginResumeProjection")) { const projection_started_ns = io_mod.nanoTimestamp(); var projection = try app.beginResumeProjection(); @@ -1965,9 +2214,13 @@ pub fn Runtime(comptime App: type) type { .app = app, .projection = &projection, }; - try writeResumeNotice(app, &sink, display_title, notice); - try replayHistoryToSink(app, &sink, state.history); - try writeRecoveryCheckpointToSink(app, &sink, state); + if (comptime @TypeOf(extras) != @TypeOf(null)) { + try extras.writeBefore(app, &sink); + } + try replayHistoryToSink(app, &sink, history); + if (comptime @TypeOf(extras) != @TypeOf(null)) { + try extras.writeAfter(app, &sink); + } const projection_finished_ns = io_mod.nanoTimestamp(); try projection.finalize(); const finalization_finished_ns = io_mod.nanoTimestamp(); @@ -1977,7 +2230,7 @@ pub fn Runtime(comptime App: type) type { "session", "event=resume_projection turns={d} project_us={d} finalize_us={d} install_us={d}", .{ - state.history.len, + history.len, @divTrunc(projection_finished_ns - projection_started_ns, std.time.ns_per_us), @divTrunc(finalization_finished_ns - projection_finished_ns, std.time.ns_per_us), @divTrunc(install_finished_ns - finalization_finished_ns, std.time.ns_per_us), @@ -1985,9 +2238,13 @@ pub fn Runtime(comptime App: type) type { ); } else { var sink = LiveHistorySink(App){ .app = app }; - try writeResumeNotice(app, &sink, display_title, notice); - try replayHistoryToSink(app, &sink, state.history); - try writeRecoveryCheckpointToSink(app, &sink, state); + if (comptime @TypeOf(extras) != @TypeOf(null)) { + try extras.writeBefore(app, &sink); + } + try replayHistoryToSink(app, &sink, history); + if (comptime @TypeOf(extras) != @TypeOf(null)) { + try extras.writeAfter(app, &sink); + } } } @@ -2409,9 +2666,18 @@ pub fn Runtime(comptime App: type) type { } pub fn appendHistoryTurn(app: *App, turn: types.HistoryTurn) !void { + closeStaleTurnPicker(app); _ = try appendHistoryTurnWithPendingPresentation(app, turn, .strict, null); } + fn closeStaleTurnPicker(app: *App) void { + if (app.session_persistence.turn_picker) |picker| { + if (!picker.isCurrent(app.session.historyLen())) { + app.session_persistence.turn_picker = null; + } + } + } + pub fn appendFinishedPrompt( app: *App, finished: types.FinishedPrompt, @@ -2756,6 +3022,284 @@ pub fn Runtime(comptime App: type) type { return .committed; } + /// Where the shell ended up when a fork did not finish. A fork has to + /// close the live session before it can run, so "which session am I in + /// now" is part of every failing answer. + pub const ForkLanding = enum { + source, + fresh_session, + no_session, + }; + + pub const ForkOutcome = union(enum) { + unavailable_during_stream, + unavailable, + out_of_range: usize, + branched: Branch, + failed: Failure, + + pub const Branch = struct { + source_id: []u8, + forked_id: []u8, + retained_turns: usize, + unverified_artifacts: bool, + }; + + pub const Failure = struct { + source_id: []u8, + /// Set when the branch exists but this shell could not enter it. + forked_id: ?[]u8, + problem: anyerror, + landing: ForkLanding, + }; + + pub fn deinit(self: *ForkOutcome, alloc: Allocator) void { + switch (self.*) { + .branched => |branch| { + alloc.free(branch.source_id); + alloc.free(branch.forked_id); + }, + .failed => |failure| { + alloc.free(failure.source_id); + if (failure.forked_id) |id| alloc.free(id); + }, + else => {}, + } + self.* = undefined; + } + }; + + /// Branches the live session at an absolute turn and moves this shell + /// into the branch. `forkSessionCopy` takes the source's writer lock, + /// which this process holds for as long as the session is open, so the + /// session is closed first. Every failure after that point runs through + /// `forkFailure`, which puts a session back before answering. + pub fn forkLiveSession(app: *App, at_turn: usize) !ForkOutcome { + if (comptime !runtime_profile.allows(App, .durable_sessions)) { + return .unavailable; + } + if (app.stream.active) return .unavailable_during_stream; + if (app.session_persistence.store == null) return .unavailable; + const active = app.session_persistence.writable orelse return .unavailable; + + const history_len = app.session.historyLen(); + const retained_turns = checkedTurnArgument(history_len, at_turn) orelse + return .{ .out_of_range = history_len }; + + const source_id = try app.alloc.dupe(u8, active.active_id); + errdefer app.alloc.free(source_id); + + const log_options = session_log.Options{ + .session_lock_deadline_ms = 0, + .commit_lock_deadline_ms = 0, + }; + try prepareLiveSessionTransition(app, .carry_forward, log_options); + + const store = app.session_persistence.store orelse + return forkFailure(app, source_id, null, log_options, error.SessionStoreUnavailable); + + var copy = store.forkSessionCopy( + app.alloc, + source_id, + retained_turns, + .{}, + ) catch |err| return forkFailure(app, source_id, null, log_options, err); + defer copy.deinit(app.alloc); + + const forked_id = app.alloc.dupe(u8, copy.forked_session_id) catch |err| + return forkFailure(app, source_id, null, log_options, err); + + if (copy.status == .indeterminate) { + return forkFailure( + app, + source_id, + forked_id, + log_options, + error.SessionForkUnconfirmed, + ); + } + + enterSessionForWrite(app, forked_id, log_options) catch |err| + return forkFailure(app, source_id, forked_id, log_options, err); + + return .{ .branched = .{ + .source_id = source_id, + .forked_id = forked_id, + .retained_turns = retained_turns, + .unverified_artifacts = copy.status == .forked_with_unverified_artifacts, + } }; + } + + /// The fork released the source's writer lock by closing the session, so + /// a failure past that point leaves the shell with no session at all. + /// Put one back before reporting. + fn forkFailure( + app: *App, + source_id: []u8, + forked_id: ?[]u8, + log_options: session_log.Options, + problem: anyerror, + ) ForkOutcome { + return .{ .failed = .{ + .source_id = source_id, + .forked_id = forked_id, + .problem = problem, + .landing = reopenSourceAfterFork(app, source_id, log_options), + } }; + } + + fn enterSessionForWrite( + app: *App, + session_id: []const u8, + log_options: session_log.Options, + ) !void { + var loaded = try loadResumeTargetForWrite( + app, + .{ .id = session_id }, + log_options, + ); + try installResumedSession(app, &loaded, .session); + requestSubagentBackgroundRecovery(app); + startResumedSessionReconciliation(app); + try app.finishLiveSessionResume(); + } + + fn reopenSourceAfterFork( + app: *App, + source_id: []const u8, + log_options: session_log.Options, + ) ForkLanding { + enterSessionForWrite(app, source_id, log_options) catch |err| { + debug_trace.logf( + "session", + "event=session_fork_source_reopen_failed id={s} err={s}", + .{ source_id, @errorName(err) }, + ); + installFreshLiveSession(app) catch |fresh_err| { + debug_trace.logf( + "session", + "event=session_fork_fresh_session_failed err={s}", + .{@errorName(fresh_err)}, + ); + return .no_session; + }; + return .fresh_session; + }; + return .source; + } + + /// Answer to one `/rewind <count>`. The gate makes the confirm step a + /// state of the request rather than a flag the caller has to track. + pub const RewindOutcome = union(enum) { + unavailable_during_stream, + out_of_range: usize, + confirm: RewindTarget, + rewound: RewindTarget, + }; + + /// Drops trailing turns from the live conversation once the same + /// request has been made twice. This process holds the session writer + /// lock, so the truncation runs here and commits through the live path + /// rather than through `Store.rewindSession`, which would deadlock + /// against the lock this shell already owns. + pub fn rewindLiveSession( + app: *App, + requested_turns: usize, + ) !RewindOutcome { + if (app.stream.active) return .unavailable_during_stream; + + const history_len = app.session.historyLen(); + const dropped_turns = checkedTurnArgument(history_len, requested_turns) orelse + return .{ .out_of_range = history_len }; + + const target = RewindTarget{ + .history_len = history_len, + .retained_turns = history_len - dropped_turns, + }; + switch (app.session_persistence.rewind_gate.request(target)) { + .confirm => return .{ .confirm = target }, + .execute => {}, + } + + try rewindToRetainedTurns(app, target.retained_turns); + return .{ .rewound = target }; + } + + fn rewindToRetainedTurns(app: *App, retained_turns: usize) !void { + app.session_persistence.rewind_gate.disarm(); + + app.session.truncateHistory(app.alloc, retained_turns); + commitJsHostSnapshot(app, "rewind"); + + app.session_persistence.write_mutex.lockUncancelable(io_mod.getIo()); + defer app.session_persistence.write_mutex.unlock(io_mod.getIo()); + if (app.session_persistence.writable) |*loaded| { + try convergeDegraded(app, loaded, .{}); + // A paused response always belongs to the tail this rewind just + // dropped, so its checkpoint cannot outlive the turns. + try commitCurrentStateReplacement(app, loaded, .rewind, .{}, true); + } + // The projection installs on top of whatever the shell already holds, + // so the old turns must go before the retained ones are redrawn. + app.shell.clearTranscript(app.alloc); + try rebuildTranscriptFromHistory(app, app.session.history.items, null); + try resetTerminalForTranscriptReplacement(app); + } + + pub fn openTurnPicker(app: *App) !bool { + if (app.stream.active or app.session.historyLen() == 0) return false; + // The picker is an inline compact menu like /statusline, so it never + // takes the alternate screen and has no terminal modes to restore. + app.session_persistence.turn_picker = TurnPicker.init(app.session.historyLen()); + app.shell.render_requests.request(.footer); + return true; + } + + pub fn moveTurnPicker(app: *App, delta: i32, visible_rows: usize) bool { + if (app.session_persistence.turn_picker) |*picker| { + picker.move(delta, visible_rows); + return true; + } + return false; + } + + pub fn cancelTurnPicker(app: *App) !bool { + if (app.session_persistence.turn_picker == null) return false; + app.session_persistence.turn_picker = null; + app.shell.render_requests.request(.footer); + return true; + } + + pub fn applyTurnPicker(app: *App) !bool { + const picker = app.session_persistence.turn_picker orelse return false; + if (!picker.isCurrent(app.session.historyLen())) { + _ = try cancelTurnPicker(app); + return true; + } + if (picker.retainedTurns() == picker.history_len) { + _ = try cancelTurnPicker(app); + return true; + } + const prompt = switch (app.session.history.items[picker.retainedTurns()]) { + .assistant => |turn| turn.user.text, + .background_command => |turn| turn.user.text, + .interrupted => |turn| turn.user.text, + .compacted_summary => "", + }; + const prompt_copy = try app.alloc.dupe(u8, prompt); + defer app.alloc.free(prompt_copy); + _ = try cancelTurnPicker(app); + try rewindToRetainedTurns(app, picker.retainedTurns()); + if (prompt_copy.len > 0) { + try app.input_runtime.textReplacementState().replace(app.alloc, prompt_copy); + } + return true; + } + + pub fn disarmRewind(app: *App) void { + app.session_persistence.rewind_gate.disarm(); + } + pub fn compactHistory(app: *App) !void { const previous_start = app.session.contextHistoryStart(); app.session.forceCompaction(); diff --git a/src/core/cli/cli_surface.zig b/src/core/cli/cli_surface.zig index de99b8da0..8c167bc48 100644 --- a/src/core/cli/cli_surface.zig +++ b/src/core/cli/cli_surface.zig @@ -291,6 +291,28 @@ const SessionRecoveryOptions = struct { } }; +const SessionForkOptions = struct { + format: output_contracts.OutputFormat = .text, + session_id: []u8, + at_turn: usize, + + fn deinit(self: *SessionForkOptions, alloc: Allocator) void { + alloc.free(self.session_id); + self.* = undefined; + } +}; + +const SessionRewindOptions = struct { + format: output_contracts.OutputFormat = .text, + session_id: []u8, + by_turns: usize, + + fn deinit(self: *SessionRewindOptions, alloc: Allocator) void { + alloc.free(self.session_id); + self.* = undefined; + } +}; + const AcpOptions = struct { model: ?[]const u8 = null, log_file: ?[]const u8 = null, @@ -1346,6 +1368,143 @@ fn runNonInteractiveWithDeps( .handled_failure; } + if (rest.len > 0 and std.mem.eql(u8, rest[0], "fork")) { + var fork = parseSessionForkArgs( + alloc, + rest[1..], + ) catch |err| { + try writeUsageOrJsonError( + alloc, + cfg.command_catalog, + deps, + .session, + "session", + err, + rest[1..], + ); + return .handled_failure; + }; + defer fork.deinit(alloc); + + const workspace_root = try io_mod.realpathAlloc(alloc, "."); + defer alloc.free(workspace_root); + var store = session_store.Store.init( + alloc, + workspace_root, + ) catch |err| { + try writeLookupFailure(alloc, deps, "session", err, fork.format); + return .handled_failure; + }; + defer store.deinit(alloc); + + const history_len = readSessionHistoryLen( + alloc, + store, + fork.session_id, + ) catch |err| { + try writeLookupFailure(alloc, deps, "session", err, fork.format); + return .handled_failure; + }; + if (fork.at_turn == 0 or fork.at_turn > history_len) { + try writeSessionForkRangeFailure( + alloc, + deps, + fork.at_turn, + history_len, + fork.format, + ); + return .handled_failure; + } + + var result = store.forkSessionCopy( + alloc, + fork.session_id, + fork.at_turn, + .{}, + ) catch |err| { + try writeLookupFailure(alloc, deps, "session", err, fork.format); + return .handled_failure; + }; + defer result.deinit(alloc); + + const text = try (output_contracts.SessionForkSnapshot{ + .result = result, + }).render(alloc, fork.format); + defer alloc.free(text); + try writeFormattedOutput(deps, text, fork.format); + return if (result.status == .indeterminate) + .handled_failure + else + .handled_success; + } + + if (rest.len > 0 and std.mem.eql(u8, rest[0], "rewind")) { + var rewind = parseSessionRewindArgs( + alloc, + rest[1..], + ) catch |err| { + try writeUsageOrJsonError( + alloc, + cfg.command_catalog, + deps, + .session, + "session", + err, + rest[1..], + ); + return .handled_failure; + }; + defer rewind.deinit(alloc); + + const workspace_root = try io_mod.realpathAlloc(alloc, "."); + defer alloc.free(workspace_root); + var store = session_store.Store.init( + alloc, + workspace_root, + ) catch |err| { + try writeLookupFailure(alloc, deps, "session", err, rewind.format); + return .handled_failure; + }; + defer store.deinit(alloc); + + const history_len = readSessionHistoryLen( + alloc, + store, + rewind.session_id, + ) catch |err| { + try writeLookupFailure(alloc, deps, "session", err, rewind.format); + return .handled_failure; + }; + if (rewind.by_turns > history_len) { + try writeSessionRewindRangeFailure( + alloc, + deps, + rewind.by_turns, + history_len, + rewind.format, + ); + return .handled_failure; + } + + var result = store.rewindSession( + alloc, + rewind.session_id, + history_len - rewind.by_turns, + .{}, + ) catch |err| { + try writeLookupFailure(alloc, deps, "session", err, rewind.format); + return .handled_failure; + }; + defer result.deinit(alloc); + + const text = try (output_contracts.SessionRewindSnapshot{ + .result = result, + }).render(alloc, rewind.format); + defer alloc.free(text); + try writeFormattedOutput(deps, text, rewind.format); + return .handled_success; + } + if (rest.len > 0 and std.mem.eql(u8, rest[0], "migrate")) { var migration = parseSessionMigrationArgs(alloc, rest[1..]) catch |err| { try writeUsageOrJsonError(alloc, cfg.command_catalog, deps, .session, "session", err, rest[1..]); @@ -3157,6 +3316,36 @@ fn writeLookupFailure( "fx session: the recovery copy could not be confirmed; the source was left unchanged\n", ); }, + error.SessionTurnOutOfRange => { + try writeStderr( + deps, + "fx session: the requested turn is out of range for this session\n", + ); + }, + error.SessionForkRequiresCurrentSchema => { + try writeStderr( + deps, + "fx session: fork only applies to current schema-v3 sessions; migrate legacy sessions first\n", + ); + }, + error.SessionRewindRequiresCurrentSchema => { + try writeStderr( + deps, + "fx session: rewind only applies to current schema-v3 sessions; migrate legacy sessions first\n", + ); + }, + error.SessionForkArtifactsUnavailable => { + try writeStderr( + deps, + "fx session: the retained turn artifacts could not be copied; the source was left unchanged\n", + ); + }, + error.SessionForkIndeterminate => { + try writeStderr( + deps, + "fx session: the forked session could not be confirmed; the source was left unchanged\n", + ); + }, error.SessionAuthorityBoundaryUnavailable, error.SessionCommitBoundaryUnavailable, => { @@ -3198,6 +3387,84 @@ fn writeLookupFailure( } } +fn writeSessionMessageFailure( + alloc: Allocator, + deps: RunDeps, + err: anyerror, + message: []const u8, + format: output_contracts.OutputFormat, +) !void { + if (format == .json) { + return writeJsonCommandFailure(alloc, deps, "session", err, message); + } + try writeStderr(deps, "fx session: "); + try writeStderr(deps, message); + try writeStderr(deps, "\n"); +} + +fn turnPlural(count: usize) []const u8 { + return if (count == 1) "" else "s"; +} + +fn writeSessionForkRangeFailure( + alloc: Allocator, + deps: RunDeps, + at_turn: usize, + history_len: usize, + format: output_contracts.OutputFormat, +) !void { + const message = try std.fmt.allocPrint( + alloc, + "turn {d} is out of range; session has {d} turn{s}", + .{ at_turn, history_len, turnPlural(history_len) }, + ); + defer alloc.free(message); + return writeSessionMessageFailure( + alloc, + deps, + error.SessionTurnOutOfRange, + message, + format, + ); +} + +fn writeSessionRewindRangeFailure( + alloc: Allocator, + deps: RunDeps, + by_turns: usize, + history_len: usize, + format: output_contracts.OutputFormat, +) !void { + const message = try std.fmt.allocPrint( + alloc, + "cannot rewind {d} turn{s}; session has {d} turn{s}", + .{ + by_turns, + turnPlural(by_turns), + history_len, + turnPlural(history_len), + }, + ); + defer alloc.free(message); + return writeSessionMessageFailure( + alloc, + deps, + error.SessionTurnOutOfRange, + message, + format, + ); +} + +fn readSessionHistoryLen( + alloc: Allocator, + store: session_store.Store, + session_id: []const u8, +) !usize { + var state = try store.loadReadOnly(alloc, session_id); + defer state.deinit(alloc); + return state.history.len; +} + fn writeSessionDetailFailure( alloc: Allocator, deps: RunDeps, @@ -3225,18 +3492,7 @@ fn writeSessionDetailFailure( ), }; defer alloc.free(message); - if (format == .json) { - return writeJsonCommandFailure( - alloc, - deps, - "session", - err, - message, - ); - } - try writeStderr(deps, "fx session: "); - try writeStderr(deps, message); - try writeStderr(deps, "\n"); + return writeSessionMessageFailure(alloc, deps, err, message, format); } fn commandFailureMessage(err: anyerror) ?[]const u8 { @@ -3248,6 +3504,8 @@ fn commandFailureMessage(err: anyerror) ?[]const u8 { error.InvalidSessionDetailArgs, error.InvalidSessionMigrationArgs, error.InvalidSessionRecoveryArgs, + error.InvalidSessionForkArgs, + error.InvalidSessionRewindArgs, error.InvalidResumeArgs, => "invalid arguments", else => null, @@ -3275,6 +3533,11 @@ fn lookupFailureMessage(err: anyerror) ?[]const u8 { error.SessionRecoveryUnsupportedSchema => "recovery is unavailable for this unsupported session version", error.SessionRecoveryBoundaryInvalid => "no exact trustworthy recovery boundary was found; the source was left unchanged", error.SessionRecoveryIndeterminate => "the recovery copy could not be confirmed; the source was left unchanged", + error.SessionTurnOutOfRange => "the requested turn is out of range for this session", + error.SessionForkRequiresCurrentSchema => "fork only applies to current schema-v3 sessions; migrate legacy sessions first", + error.SessionRewindRequiresCurrentSchema => "rewind only applies to current schema-v3 sessions; migrate legacy sessions first", + error.SessionForkArtifactsUnavailable => "the retained turn artifacts could not be copied; the source was left unchanged", + error.SessionForkIndeterminate => "the forked session could not be confirmed; the source was left unchanged", error.SessionAuthorityBoundaryUnavailable, error.SessionCommitBoundaryUnavailable, => "session authority is temporarily unavailable while an incomplete commit is resolved", @@ -3788,6 +4051,94 @@ fn parseSessionRecoveryArgs( }; } +fn parseSessionForkArgs( + alloc: Allocator, + args: []const [:0]const u8, +) !SessionForkOptions { + var format: output_contracts.OutputFormat = .text; + var session_id: ?[]u8 = null; + var at_turn: ?usize = null; + errdefer if (session_id) |id| alloc.free(id); + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (std.mem.eql(u8, arg, "--json")) { + format = .json; + continue; + } + if (std.mem.eql(u8, arg, "--at")) { + if (at_turn != null) return error.InvalidSessionForkArgs; + i += 1; + if (i >= args.len) return error.InvalidSessionForkArgs; + at_turn = parseTurnCount(args[i]) catch + return error.InvalidSessionForkArgs; + continue; + } + if (session_id != null) return error.InvalidSessionForkArgs; + const exact_id = std.mem.eql(u8, arg, "--id"); + if (exact_id) { + i += 1; + if (i >= args.len) return error.InvalidSessionForkArgs; + } + const trimmed = std.mem.trim(u8, args[i], " \t\r\n"); + if (trimmed.len == 0) return error.InvalidSessionForkArgs; + session_id = try alloc.dupe(u8, trimmed); + } + return .{ + .format = format, + .session_id = session_id orelse return error.InvalidSessionForkArgs, + .at_turn = at_turn orelse return error.InvalidSessionForkArgs, + }; +} + +fn parseSessionRewindArgs( + alloc: Allocator, + args: []const [:0]const u8, +) !SessionRewindOptions { + var format: output_contracts.OutputFormat = .text; + var session_id: ?[]u8 = null; + var by_turns: ?usize = null; + errdefer if (session_id) |id| alloc.free(id); + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (std.mem.eql(u8, arg, "--json")) { + format = .json; + continue; + } + if (std.mem.eql(u8, arg, "--by")) { + if (by_turns != null) return error.InvalidSessionRewindArgs; + i += 1; + if (i >= args.len) return error.InvalidSessionRewindArgs; + by_turns = parseTurnCount(args[i]) catch + return error.InvalidSessionRewindArgs; + continue; + } + if (session_id != null) return error.InvalidSessionRewindArgs; + const exact_id = std.mem.eql(u8, arg, "--id"); + if (exact_id) { + i += 1; + if (i >= args.len) return error.InvalidSessionRewindArgs; + } + const trimmed = std.mem.trim(u8, args[i], " \t\r\n"); + if (trimmed.len == 0) return error.InvalidSessionRewindArgs; + session_id = try alloc.dupe(u8, trimmed); + } + return .{ + .format = format, + .session_id = session_id orelse return error.InvalidSessionRewindArgs, + .by_turns = by_turns orelse return error.InvalidSessionRewindArgs, + }; +} + +fn parseTurnCount(raw: []const u8) !usize { + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); + if (trimmed.len == 0) return error.InvalidTurnCount; + return std.fmt.parseInt(usize, trimmed, 10) catch error.InvalidTurnCount; +} + fn parseResumeArgs( alloc: Allocator, command_catalog: CommandCatalog, diff --git a/src/core/output/output_contracts.zig b/src/core/output/output_contracts.zig index 271346b91..a4be70946 100644 --- a/src/core/output/output_contracts.zig +++ b/src/core/output/output_contracts.zig @@ -1302,6 +1302,136 @@ pub const SessionRecoverySnapshot = struct { } }; +pub const SessionForkSnapshot = struct { + result: session_store.SessionForkResult, + + pub fn render( + self: SessionForkSnapshot, + alloc: Allocator, + format: OutputFormat, + ) ![]u8 { + return switch (format) { + .text => self.renderText(alloc), + .json => self.renderJson(alloc), + }; + } + + pub fn renderText(self: SessionForkSnapshot, alloc: Allocator) ![]u8 { + if (self.result.status == .indeterminate) { + return std.fmt.allocPrint( + alloc, + "[session fork] could not confirm branch {s}\nsource: {s} (unchanged)\nresolve: fx --resume {s}\ninspect: fx doctor\n", + .{ + self.result.forked_session_id, + self.result.source_session_id, + self.result.forked_session_id, + }, + ); + } + if (self.result.status == .forked_with_unverified_artifacts) { + return std.fmt.allocPrint( + alloc, + "[session fork] forked {s} to {s}\nhistory_turns: {d} of {d}\nwarning: legacy command artifacts could not be authenticated\nresume: fx --resume {s}\n", + .{ + self.result.source_session_id, + self.result.forked_session_id, + self.result.history_len, + self.result.source_history_len, + self.result.forked_session_id, + }, + ); + } + return std.fmt.allocPrint( + alloc, + "[session fork] forked {s} to {s}\nhistory_turns: {d} of {d}\nresume: fx --resume {s}\n", + .{ + self.result.source_session_id, + self.result.forked_session_id, + self.result.history_len, + self.result.source_history_len, + self.result.forked_session_id, + }, + ); + } + + pub fn renderJson(self: SessionForkSnapshot, alloc: Allocator) ![]u8 { + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + try out.writer.writeAll("{\"kind\":\"session_fork\",\"source_id\":"); + try std.json.Stringify.value( + self.result.source_session_id, + .{}, + &out.writer, + ); + try out.writer.writeAll(",\"forked_id\":"); + try std.json.Stringify.value( + self.result.forked_session_id, + .{}, + &out.writer, + ); + try out.writer.writeAll(",\"status\":"); + try std.json.Stringify.value( + @tagName(self.result.status), + .{}, + &out.writer, + ); + try out.writer.print( + ",\"history_turns\":{d},\"source_history_turns\":{d}}}", + .{ self.result.history_len, self.result.source_history_len }, + ); + return try out.toOwnedSlice(); + } +}; + +pub const SessionRewindSnapshot = struct { + result: session_store.SessionRewindResult, + + pub fn render( + self: SessionRewindSnapshot, + alloc: Allocator, + format: OutputFormat, + ) ![]u8 { + return switch (format) { + .text => self.renderText(alloc), + .json => self.renderJson(alloc), + }; + } + + pub fn renderText(self: SessionRewindSnapshot, alloc: Allocator) ![]u8 { + return std.fmt.allocPrint( + alloc, + "[session rewind] {s}\nhistory_turns: {d}\nremoved_turns: {d}\n", + .{ + self.result.session_id, + self.result.history_len, + self.result.removed_turn_count, + }, + ); + } + + pub fn renderJson(self: SessionRewindSnapshot, alloc: Allocator) ![]u8 { + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + try out.writer.writeAll("{\"kind\":\"session_rewind\",\"id\":"); + try std.json.Stringify.value( + self.result.session_id, + .{}, + &out.writer, + ); + try out.writer.writeAll(",\"status\":"); + try std.json.Stringify.value( + @tagName(self.result.status), + .{}, + &out.writer, + ); + try out.writer.print( + ",\"history_turns\":{d},\"removed_turns\":{d}}}", + .{ self.result.history_len, self.result.removed_turn_count }, + ); + return try out.toOwnedSlice(); + } +}; + pub const DoctorSnapshot = struct { workspace_root: []const u8, model: []const u8, @@ -2896,6 +3026,114 @@ test "core session recovery snapshot text and json stay stable" { ); } +test "core session fork snapshot text and json stay stable" { + const result = session_store.SessionForkResult{ + .source_session_id = @constCast("source-session"), + .forked_session_id = @constCast("forked-session"), + .history_len = 3, + .source_history_len = 7, + }; + + const text = try (SessionForkSnapshot{ .result = result }).renderText( + std.testing.allocator, + ); + defer std.testing.allocator.free(text); + try std.testing.expectEqualStrings( + "[session fork] forked source-session to forked-session\nhistory_turns: 3 of 7\nresume: fx --resume forked-session\n", + text, + ); + + const json = try (SessionForkSnapshot{ .result = result }).renderJson( + std.testing.allocator, + ); + defer std.testing.allocator.free(json); + try std.testing.expectEqualStrings( + "{\"kind\":\"session_fork\",\"source_id\":\"source-session\",\"forked_id\":\"forked-session\",\"status\":\"forked\",\"history_turns\":3,\"source_history_turns\":7}", + json, + ); + + const partial = session_store.SessionForkResult{ + .source_session_id = @constCast("source-session"), + .forked_session_id = @constCast("partial-session"), + .history_len = 3, + .source_history_len = 7, + .status = .forked_with_unverified_artifacts, + }; + const partial_text = try (SessionForkSnapshot{ + .result = partial, + }).renderText(std.testing.allocator); + defer std.testing.allocator.free(partial_text); + try std.testing.expectEqualStrings( + "[session fork] forked source-session to partial-session\nhistory_turns: 3 of 7\nwarning: legacy command artifacts could not be authenticated\nresume: fx --resume partial-session\n", + partial_text, + ); + + const indeterminate = session_store.SessionForkResult{ + .source_session_id = @constCast("source-session"), + .forked_session_id = @constCast("target-session"), + .history_len = 3, + .source_history_len = 7, + .status = .indeterminate, + }; + const warning = try (SessionForkSnapshot{ + .result = indeterminate, + }).renderText(std.testing.allocator); + defer std.testing.allocator.free(warning); + try std.testing.expectEqualStrings( + "[session fork] could not confirm branch target-session\nsource: source-session (unchanged)\nresolve: fx --resume target-session\ninspect: fx doctor\n", + warning, + ); + const indeterminate_json = try (SessionForkSnapshot{ + .result = indeterminate, + }).renderJson(std.testing.allocator); + defer std.testing.allocator.free(indeterminate_json); + try std.testing.expectEqualStrings( + "{\"kind\":\"session_fork\",\"source_id\":\"source-session\",\"forked_id\":\"target-session\",\"status\":\"indeterminate\",\"history_turns\":3,\"source_history_turns\":7}", + indeterminate_json, + ); +} + +test "core session rewind snapshot text and json stay stable" { + const result = session_store.SessionRewindResult{ + .session_id = @constCast("rewound-session"), + .history_len = 4, + .removed_turn_count = 2, + }; + + const text = try (SessionRewindSnapshot{ .result = result }).renderText( + std.testing.allocator, + ); + defer std.testing.allocator.free(text); + try std.testing.expectEqualStrings( + "[session rewind] rewound-session\nhistory_turns: 4\nremoved_turns: 2\n", + text, + ); + + const json = try (SessionRewindSnapshot{ .result = result }).renderJson( + std.testing.allocator, + ); + defer std.testing.allocator.free(json); + try std.testing.expectEqualStrings( + "{\"kind\":\"session_rewind\",\"id\":\"rewound-session\",\"status\":\"rewound\",\"history_turns\":4,\"removed_turns\":2}", + json, + ); + + const unchanged = session_store.SessionRewindResult{ + .session_id = @constCast("rewound-session"), + .history_len = 6, + .removed_turn_count = 0, + .status = .already_at_target, + }; + const unchanged_json = try (SessionRewindSnapshot{ + .result = unchanged, + }).renderJson(std.testing.allocator); + defer std.testing.allocator.free(unchanged_json); + try std.testing.expectEqualStrings( + "{\"kind\":\"session_rewind\",\"id\":\"rewound-session\",\"status\":\"already_at_target\",\"history_turns\":6,\"removed_turns\":0}", + unchanged_json, + ); +} + test "core doctor snapshot text and json stay stable" { const checks = [_]doctor_runtime.Check{ .{ .name = @constCast("auth"), .status = .ok, .detail = @constCast("AI_GATEWAY_API_KEY is configured") }, diff --git a/src/core/session/session.zig b/src/core/session/session.zig index 96615e5aa..66fb2c97d 100644 --- a/src/core/session/session.zig +++ b/src/core/session/session.zig @@ -1859,6 +1859,22 @@ pub const SessionRuntime = struct { self.context_history_start = 0; } + /// Drops the trailing turns from the live conversation. The turns are gone + /// from this process; persisting the shorter history is the caller's job. + pub fn truncateHistory( + self: *SessionRuntime, + alloc: Allocator, + retained_turns: usize, + ) void { + dropHistoryTurnsAfter( + alloc, + self.history.items, + &self.context_history_start, + retained_turns, + ); + self.history.shrinkRetainingCapacity(retained_turns); + } + pub fn historyLen(self: *const SessionRuntime) usize { return self.history.items.len; } @@ -2092,6 +2108,20 @@ pub fn freeImageAttachmentSlice(alloc: Allocator, attachments: []ImageAttachment } if (attachments.len > 0) alloc.free(attachments); } +/// Frees the turns after `retained_turns` and clamps the model-context cursor +/// so it can never point past the shortened history. The caller shrinks its own +/// container; only the freeing and the clamp are shared. +pub fn dropHistoryTurnsAfter( + alloc: Allocator, + history: []const HistoryTurn, + context_history_start: *usize, + retained_turns: usize, +) void { + std.debug.assert(retained_turns <= history.len); + context_history_start.* = @min(context_history_start.*, retained_turns); + for (history[retained_turns..]) |turn| freeHistoryTurn(alloc, turn); +} + /// Frees an owned history slice; callers pass slices returned by session helpers. pub fn freeHistoryTurnSlice(alloc: Allocator, turns: []HistoryTurn) void { for (turns) |turn| freeHistoryTurn(alloc, turn); diff --git a/src/core/session/session_event.zig b/src/core/session/session_event.zig index 4a63af688..eb5d849d5 100644 --- a/src/core/session/session_event.zig +++ b/src/core/session/session_event.zig @@ -31,6 +31,8 @@ pub const ReplacementReason = enum { migration, recovery, log_compaction, + fork, + rewind, }; pub const SessionStarted = struct { diff --git a/src/core/session/session_log.zig b/src/core/session/session_log.zig index 04600db38..a65c7afee 100644 --- a/src/core/session/session_log.zig +++ b/src/core/session/session_log.zig @@ -706,8 +706,8 @@ pub const LoadedWritableSession = struct { state.workspace_root, ); const may_defer_cache = same_workspace and switch (reason) { - .compaction, .log_compaction => true, - .migration, .recovery => false, + .compaction, .log_compaction, .rewind => true, + .migration, .recovery, .fork => false, }; const cache_deferred = if (may_defer_cache) try self.prepareCommitLifecycleOpportunistic(alloc, options) diff --git a/src/core/session/session_store.zig b/src/core/session/session_store.zig index 18b667445..2218d823e 100644 --- a/src/core/session/session_store.zig +++ b/src/core/session/session_store.zig @@ -69,8 +69,10 @@ const latestCachePublish = latest_pointer.latestCachePublish; const latestCacheWriteDeferred = latest_pointer.latestCacheWriteDeferred; const latest_sessions_lock_file = latest_pointer.latest_sessions_lock_file; const latest_sessions_dir = latest_pointer.latest_sessions_dir; -const recovery_staging_dir = "recovery+staging"; -const recovery_staging_lock_file = "recovery-staging.lock"; +// The on-disk names stay `recovery+staging` so staged directories written by +// earlier versions are still found and cleaned up. +const staging_dir = "recovery+staging"; +const staging_lock_file = "recovery-staging.lock"; const usage_recovery_dir = profile_paths.usage_recovery_dir_name; const usage_recovery_marker_prefix = "v1 "; const max_usage_recovery_marker_bytes = @@ -144,8 +146,12 @@ pub const ResumeTarget = store_types.ResumeTarget; pub const ResumeViewAdmission = session_log.ResumeViewAdmission; pub const SessionMigrationResult = store_types.SessionMigrationResult; pub const SessionMigrationStatus = store_types.SessionMigrationStatus; +pub const SessionForkResult = store_types.SessionForkResult; +pub const SessionForkStatus = store_types.SessionForkStatus; pub const SessionRecoveryResult = store_types.SessionRecoveryResult; pub const SessionRecoveryStatus = store_types.SessionRecoveryStatus; +pub const SessionRewindResult = store_types.SessionRewindResult; +pub const SessionRewindStatus = store_types.SessionRewindStatus; pub const SessionSummary = store_types.SessionSummary; pub const HistoryPage = store_types.HistoryPage; @@ -657,7 +663,7 @@ pub const Store = struct { return loaded; } - fn startRecoveryStagedSession( + fn startStagedSession( self: Store, alloc: Allocator, staging_root: *session_log.Root, @@ -675,7 +681,7 @@ pub const Store = struct { return loaded; } - fn initRecoveryStagingRoot( + fn initStagingRoot( self: Store, alloc: Allocator, ) !session_log.Root { @@ -684,20 +690,20 @@ pub const Store = struct { return error.SessionStoreUnavailable); var staging = try io_mod.openOrCreateVerifiedPrivateDir( sessions, - recovery_staging_dir, + staging_dir, ); errdefer staging.close(); return .{ .sessions = staging, .display_root = try std.fs.path.join( alloc, - &.{ self.sessions_dir, recovery_staging_dir }, + &.{ self.sessions_dir, staging_dir }, ), .mode = .writable, }; } - fn deinitRecoveryStagingRoot( + fn deinitStagingRoot( self: Store, alloc: Allocator, staging_root: *session_log.Root, @@ -706,18 +712,18 @@ pub const Store = struct { const sessions = &(self.canonical_root.sessions orelse return); sessions.dir.deleteDir( io_mod.getIo(), - recovery_staging_dir, + staging_dir, ) catch return; io_mod.syncVerifiedDir(sessions.dir) catch |err| { debug_trace.logf( "session", - "event=recovery_staging_cleanup disposition=indeterminate err={s}", + "event=session_staging_cleanup disposition=indeterminate err={s}", .{@errorName(err)}, ); }; } - fn acquireRecoveryStagingLock( + fn acquireStagingLock( self: Store, deadline_ms: u64, ) !io_mod.TimedAdvisoryLock { @@ -726,7 +732,7 @@ pub const Store = struct { return error.SessionStoreUnavailable); return io_mod.acquireTimedAdvisoryLock( sessions, - recovery_staging_lock_file, + staging_lock_file, deadline_ms, ) catch |err| switch (err) { error.LockBusy => error.SessionBusy, @@ -735,7 +741,7 @@ pub const Store = struct { }; } - fn cleanupAbandonedRecoveryStages( + fn cleanupAbandonedStages( staging_root: *session_log.Root, ) !void { const staging = &(staging_root.sessions orelse @@ -749,18 +755,18 @@ pub const Store = struct { changed = true; debug_trace.logf( "session", - "event=recovery_staging_abandoned_target_removed", + "event=session_staging_abandoned_target_removed", .{}, ); } if (changed) try io_mod.syncVerifiedDir(staging.dir); } - fn promoteRecoveryStagedSession( + fn promoteStagedSession( self: Store, staging_root: *session_log.Root, session_id: []const u8, - ) !RecoveryPromotionStatus { + ) !StagingPromotionStatus { const staging = &(staging_root.sessions orelse return error.SessionStoreUnavailable); const sessions = &(self.canonical_root.sessions orelse @@ -779,7 +785,7 @@ pub const Store = struct { } /// Consumes `loaded` on every return. - fn discardRecoveryStagedSession( + fn discardStagedSession( staging_root: *session_log.Root, alloc: Allocator, loaded: *LoadedWritableSession, @@ -790,7 +796,7 @@ pub const Store = struct { { debug_trace.logf( "session", - "event=recovery_staging_discard disposition=retained reason=guard_failed", + "event=session_staging_discard disposition=retained reason=guard_failed", .{}, ); return .retained; @@ -802,7 +808,7 @@ pub const Store = struct { ) catch |err| { debug_trace.logf( "session", - "event=recovery_staging_discard disposition=retained reason=store_root_unverified err={s}", + "event=session_staging_discard disposition=retained reason=store_root_unverified err={s}", .{@errorName(err)}, ); return .retained; @@ -810,7 +816,7 @@ pub const Store = struct { if (!writer_belongs_to_store) { debug_trace.logf( "session", - "event=recovery_staging_discard disposition=retained reason=store_root_mismatch", + "event=session_staging_discard disposition=retained reason=store_root_mismatch", .{}, ); return .retained; @@ -818,7 +824,7 @@ pub const Store = struct { const sessions = &(staging_root.sessions orelse { debug_trace.logf( "session", - "event=recovery_staging_discard disposition=indeterminate stage=sessions_root", + "event=session_staging_discard disposition=indeterminate stage=sessions_root", .{}, ); return .indeterminate; @@ -826,7 +832,7 @@ pub const Store = struct { sessions.dir.deleteTree(io_mod.getIo(), loaded.active_id) catch |err| { debug_trace.logf( "session", - "event=recovery_staging_discard disposition=indeterminate stage=delete err={s}", + "event=session_staging_discard disposition=indeterminate stage=delete err={s}", .{@errorName(err)}, ); return .indeterminate; @@ -834,14 +840,14 @@ pub const Store = struct { io_mod.syncVerifiedDir(sessions.dir) catch |err| { debug_trace.logf( "session", - "event=recovery_staging_discard disposition=indeterminate stage=sync err={s}", + "event=session_staging_discard disposition=indeterminate stage=sync err={s}", .{@errorName(err)}, ); return .indeterminate; }; debug_trace.logf( "session", - "event=recovery_staging_discard disposition=discarded", + "event=session_staging_discard disposition=discarded", .{}, ); return .discarded; @@ -3943,16 +3949,16 @@ pub const Store = struct { alloc.free(recovered.id); recovered.id = replacement_id; - var initial = try recoveryInitialState(alloc, recovered); + var initial = try initialSeedState(alloc, recovered); defer initial.deinit(alloc); - var staging_lock = try self.acquireRecoveryStagingLock( + var staging_lock = try self.acquireStagingLock( options.session_lock_deadline_ms, ); defer staging_lock.release(); - var staging_root = try self.initRecoveryStagingRoot(alloc); - defer self.deinitRecoveryStagingRoot(alloc, &staging_root); - try cleanupAbandonedRecoveryStages(&staging_root); - var target = try self.startRecoveryStagedSession( + var staging_root = try self.initStagingRoot(alloc); + defer self.deinitStagingRoot(alloc, &staging_root); + try cleanupAbandonedStages(&staging_root); + var target = try self.startStagedSession( alloc, &staging_root, initial, @@ -3964,7 +3970,7 @@ pub const Store = struct { if (target_promoted) { target.deinit(alloc); } else { - const disposition = discardRecoveryStagedSession( + const disposition = discardStagedSession( &staging_root, alloc, &target, @@ -4038,7 +4044,7 @@ pub const Store = struct { @errorName(resolve_err), }, ); - const disposition = discardRecoveryStagedSession( + const disposition = discardStagedSession( &staging_root, alloc, &target, @@ -4055,7 +4061,7 @@ pub const Store = struct { }; }; if (!try session_log.durableStatesEqual(target.state, recovered)) { - const disposition = discardRecoveryStagedSession( + const disposition = discardStagedSession( &staging_root, alloc, &target, @@ -4076,7 +4082,7 @@ pub const Store = struct { "event=session_recovery_target_indeterminate target={s} validation_err={s}", .{ recovered_id, @errorName(err) }, ); - const disposition = discardRecoveryStagedSession( + const disposition = discardStagedSession( &staging_root, alloc, &target, @@ -4091,7 +4097,7 @@ pub const Store = struct { } return error.SessionRecoveryIndeterminate; }; - const promotion = self.promoteRecoveryStagedSession( + const promotion = self.promoteStagedSession( &staging_root, recovered_id, ) catch |err| { @@ -4179,6 +4185,369 @@ pub const Store = struct { .recovered, }; } + + /// Creates a new session holding the first `retained_turns` turns of a + /// healthy source. The source is locked for the read and never modified. + pub fn forkSessionCopy( + self: Store, + alloc: Allocator, + session_id: []const u8, + retained_turns: usize, + options: session_log.Options, + ) !SessionForkResult { + try validateSessionId(session_id); + var source = try self.openWritableSessionDir( + alloc, + session_id, + options.session_lock_deadline_ms, + ); + defer source.deinit(alloc); + const authority = try classifyAuthority( + alloc, + &source.dir, + session_id, + ); + if (authority != .schema_v3) { + return error.SessionForkRequiresCurrentSchema; + } + + var forked = blk: { + var root = self.canonical_root; + var state = root.loadReadOnly( + alloc, + session_id, + options, + ) catch |err| return mapReplayError(err); + errdefer state.deinit(alloc); + try resolveSessionSnapshotLocators( + alloc, + state.history, + self.sessions_dir, + session_id, + ); + break :blk state; + }; + defer forked.deinit(alloc); + + const source_history_len = forked.history.len; + if (retained_turns == 0 or retained_turns > source_history_len) { + return error.SessionTurnOutOfRange; + } + try truncateSessionHistory(alloc, &forked, retained_turns); + // The branch inherits the source token totals. There is no per-turn + // usage ledger to recompute a truncated total from, and zeroing would + // misreport what producing this history actually cost. + if (forked.recovery_checkpoint) |*checkpoint| { + checkpoint.deinit(alloc); + forked.recovery_checkpoint = null; + } + const forked_at_ms = io_mod.milliTimestamp(); + forked.created_at_ms = forked_at_ms; + forked.updated_at_ms = forked_at_ms; + + const source_dir_path = try sessionDirPath( + alloc, + self.sessions_dir, + session_id, + ); + defer alloc.free(source_dir_path); + var source_children = try session_child_store.SessionChildCapability.init( + alloc, + source.dir.dir, + source_dir_path, + .read_only, + ); + defer source_children.deinit(); + + const source_id = try alloc.dupe(u8, session_id); + errdefer alloc.free(source_id); + const forked_id = try generateSessionId(alloc); + errdefer alloc.free(forked_id); + const replacement_id = try alloc.dupe(u8, forked_id); + alloc.free(forked.id); + forked.id = replacement_id; + + var initial = try initialSeedState(alloc, forked); + defer initial.deinit(alloc); + var staging_lock = try self.acquireStagingLock( + options.session_lock_deadline_ms, + ); + defer staging_lock.release(); + var staging_root = try self.initStagingRoot(alloc); + defer self.deinitStagingRoot(alloc, &staging_root); + try cleanupAbandonedStages(&staging_root); + var target = try self.startStagedSession( + alloc, + &staging_root, + initial, + options, + ); + var target_owned = true; + var target_promoted = false; + errdefer if (target_owned) { + if (target_promoted) { + target.deinit(alloc); + } else { + const disposition = discardStagedSession( + &staging_root, + alloc, + &target, + ); + if (disposition != .discarded) { + debug_trace.logf( + "session", + "event=session_fork_unpublished_target_cleanup disposition={s}", + .{@tagName(disposition)}, + ); + } + } + target_owned = false; + }; + + const staged_target_dir = try sessionDirPath( + alloc, + staging_root.display_root, + forked_id, + ); + defer alloc.free(staged_target_dir); + const staged_target_images = try std.fs.path.join( + alloc, + &.{ staged_target_dir, "images" }, + ); + defer alloc.free(staged_target_images); + const target_dir = try sessionDirPath( + alloc, + self.sessions_dir, + forked_id, + ); + defer alloc.free(target_dir); + const target_images = try std.fs.path.join( + alloc, + &.{ target_dir, "images" }, + ); + defer alloc.free(target_images); + copyRecoveredImageSnapshots( + alloc, + forked.history, + staged_target_images, + ) catch |err| return mapForkArtifactError(err); + rebaseRecoveredImageSnapshots( + alloc, + forked.history, + staged_target_images, + target_images, + ) catch |err| return mapForkArtifactError(err); + const contains_unverified_artifacts = copyRecoveredManagedChildren( + alloc, + forked.history, + &source_children, + target.child_capability orelse + return error.SessionChildStoreFailed, + ) catch |err| return mapForkArtifactError(err); + _ = target.commitStateReplacement( + alloc, + forked, + .fork, + .rollback_before_adapter_continue, + options, + ) catch |commit_err| { + target.namespace_confirmation_required = true; + target.validateResumeBoundary(alloc, options) catch |resolve_err| { + debug_trace.logf( + "session", + "event=session_fork_target_indeterminate target={s} commit_err={s} resolve_err={s}", + .{ + forked_id, + @errorName(commit_err), + @errorName(resolve_err), + }, + ); + discardForkTarget(&staging_root, alloc, &target); + target_owned = false; + return error.SessionForkIndeterminate; + }; + }; + if (!try session_log.durableStatesEqual(target.state, forked)) { + discardForkTarget(&staging_root, alloc, &target); + target_owned = false; + return error.SessionForkIndeterminate; + } + target.validateResumeBoundary(alloc, options) catch |err| { + debug_trace.logf( + "session", + "event=session_fork_target_indeterminate target={s} validation_err={s}", + .{ forked_id, @errorName(err) }, + ); + discardForkTarget(&staging_root, alloc, &target); + target_owned = false; + return error.SessionForkIndeterminate; + }; + const promotion = self.promoteStagedSession( + &staging_root, + forked_id, + ) catch |err| { + debug_trace.logf( + "session", + "event=session_fork_target_promotion_failed target={s} err={s}", + .{ forked_id, @errorName(err) }, + ); + return error.SessionForkIndeterminate; + }; + target_promoted = true; + const indeterminate = SessionForkResult{ + .source_session_id = source_id, + .forked_session_id = forked_id, + .history_len = forked.history.len, + .source_history_len = source_history_len, + .status = .indeterminate, + }; + if (promotion == .indeterminate) { + target.deinit(alloc); + target_owned = false; + return indeterminate; + } + self.publishRecoveredLatestPointer( + alloc, + forked, + target.position, + source_id, + options, + ) catch |err| { + debug_trace.logf( + "session", + "event=session_fork_target_indeterminate target={s} latest_err={s}", + .{ forked_id, @errorName(err) }, + ); + target.deinit(alloc); + target_owned = false; + return indeterminate; + }; + target.deinit(alloc); + target_owned = false; + + var verified = self.resumeExactForWrite( + alloc, + forked_id, + forked.workspace_root, + false, + .{ .log = options }, + ) catch |err| { + debug_trace.logf( + "session", + "event=session_fork_target_indeterminate target={s} verify_err={s}", + .{ forked_id, @errorName(err) }, + ); + return indeterminate; + }; + defer verified.deinit(alloc); + if (!try session_log.durableStatesEqual(verified.state, forked)) { + return indeterminate; + } + + return .{ + .source_session_id = source_id, + .forked_session_id = forked_id, + .history_len = forked.history.len, + .source_history_len = source_history_len, + .status = if (contains_unverified_artifacts) + .forked_with_unverified_artifacts + else + .forked, + }; + } + + /// Drops every turn after `retained_turns` from a session in place, keeping + /// its id. The rewind is recorded as a state replacement, so the dropped + /// turns stay in the event log until it is compacted and their artifacts + /// are left on disk. Any paused response is cleared too, since it describes + /// a turn past the retained tail. + pub fn rewindSession( + self: Store, + alloc: Allocator, + session_id: []const u8, + retained_turns: usize, + options: session_log.Options, + ) !SessionRewindResult { + var loaded = try self.openSchemaV3ForWriteInPlace( + alloc, + session_id, + options, + ); + defer loaded.deinit(alloc); + + const history_len = loaded.state.history.len; + if (retained_turns > history_len) return error.SessionTurnOutOfRange; + if (retained_turns == history_len) { + return .{ + .session_id = try alloc.dupe(u8, loaded.active_id), + .history_len = history_len, + .removed_turn_count = 0, + .status = .already_at_target, + }; + } + + var rewound = try loaded.state.dupe(alloc); + defer rewound.deinit(alloc); + try truncateSessionHistory(alloc, &rewound, retained_turns); + if (rewound.recovery_checkpoint) |*checkpoint| { + checkpoint.deinit(alloc); + rewound.recovery_checkpoint = null; + } + rewound.updated_at_ms = io_mod.milliTimestamp(); + _ = try loaded.commitStateReplacement( + alloc, + rewound, + .rewind, + .rollback_before_adapter_continue, + options, + ); + + return .{ + .session_id = try alloc.dupe(u8, loaded.active_id), + .history_len = retained_turns, + .removed_turn_count = history_len - retained_turns, + .status = .rewound, + }; + } + + /// Opens a current-schema session for writing without touching its + /// workspace binding, so a session saved from another workspace can still + /// be rewritten in place. + fn openSchemaV3ForWriteInPlace( + self: Store, + alloc: Allocator, + session_id: []const u8, + options: session_log.Options, + ) !LoadedWritableSession { + try validateSessionId(session_id); + var session_dir = try self.openSessionDir(session_id); + defer session_dir.close(); + const authority = try classifyAuthority(alloc, &session_dir, session_id); + if (authority != .schema_v3) { + return error.SessionRewindRequiresCurrentSchema; + } + var root = self.canonical_root; + var loaded = root.resumeForWrite( + alloc, + session_id, + options, + ) catch |err| return mapReplayError(err); + errdefer loaded.deinit(alloc); + try resolveSessionSnapshotLocators( + alloc, + loaded.state.history, + self.sessions_dir, + loaded.active_id, + ); + if (loaded.commit_lifecycle == null) { + try self.installLatestCacheLifecycle( + alloc, + &loaded, + options.test_controls, + ); + } + return loaded; + } }; pub const PristineDiscardDisposition = enum { @@ -4187,12 +4556,55 @@ pub const PristineDiscardDisposition = enum { indeterminate, }; -const RecoveryPromotionStatus = enum { +const StagingPromotionStatus = enum { promoted, indeterminate, }; -fn recoveryInitialState( +fn truncateSessionHistory( + alloc: Allocator, + state: *session_codec.DurableSessionState, + retained_turns: usize, +) !void { + const dropped = state.history; + const retained: []session.HistoryTurn = if (retained_turns == dropped.len) + dropped + else if (retained_turns == 0) + &.{} + else + try alloc.dupe(session.HistoryTurn, dropped[0..retained_turns]); + session.dropHistoryTurnsAfter( + alloc, + dropped, + &state.context_history_start, + retained_turns, + ); + if (retained_turns != dropped.len) alloc.free(dropped); + state.history = retained; +} + +fn mapForkArtifactError(err: anyerror) anyerror { + return switch (err) { + error.SessionRecoveryBoundaryInvalid => error.SessionForkArtifactsUnavailable, + else => err, + }; +} + +fn discardForkTarget( + staging_root: *session_log.Root, + alloc: Allocator, + target: *LoadedWritableSession, +) void { + const disposition = Store.discardStagedSession(staging_root, alloc, target); + if (disposition == .discarded) return; + debug_trace.logf( + "session", + "event=session_fork_staged_target_cleanup disposition={s}", + .{@tagName(disposition)}, + ); +} + +fn initialSeedState( alloc: Allocator, recovered: session_codec.DurableSessionState, ) !session_codec.DurableSessionState { @@ -9678,14 +10090,14 @@ test "abandoned recovery staging stays invisible and does not replace unrelated ctx.workspace, ); defer staged_state.deinit(alloc); - var staging_root = try ctx.store.initRecoveryStagingRoot(alloc); + var staging_root = try ctx.store.initStagingRoot(alloc); const abandoned_path = try sessionDirPath( alloc, staging_root.display_root, staged_state.id, ); defer alloc.free(abandoned_path); - var abandoned = try ctx.store.startRecoveryStagedSession( + var abandoned = try ctx.store.startStagedSession( alloc, &staging_root, staged_state, @@ -13869,3 +14281,401 @@ test "history page allocation failure sweep frees replay and page ownership" { try std.testing.expectEqual(failing.allocated_bytes, failing.freed_bytes); } } + +fn writeForkSourceFixture( + alloc: Allocator, + store: Store, + id: []const u8, + workspace_root: []const u8, + count: usize, + context_history_start: usize, +) !void { + var state = try testDurableState(alloc, id, workspace_root); + defer state.deinit(alloc); + var created = try store.startWritableSession(alloc, state); + created.deinit(alloc); + if (count == 0 and context_history_start == 0) return; + + var writable = try store.resumeForWrite(alloc, id); + defer writable.deinit(alloc); + const history = try makeTaggedHistoryPageTurns(alloc, count, "turn"); + defer session.freeHistoryTurnSlice(alloc, history); + const desired = session_codec.DurableSessionState{ + .id = writable.state.id, + .origin_workspace_root = writable.state.origin_workspace_root, + .workspace_root = writable.state.workspace_root, + .created_at_ms = writable.state.created_at_ms, + .updated_at_ms = 20, + .conversation_language = writable.state.conversation_language, + .preferences = writable.state.preferences, + .history = history, + .context_history_start = context_history_start, + .total_input_tokens = 4321, + .total_output_tokens = 8765, + }; + _ = try writable.commitStateReplacement( + alloc, + desired, + .recovery, + .retry_expected_tail, + .{}, + ); +} + +fn expectHistoryPrompts( + state: session_codec.DurableSessionState, + expected: []const []const u8, +) !void { + try std.testing.expectEqual(expected.len, state.history.len); + for (state.history, expected) |turn, prompt| { + try std.testing.expectEqualStrings(prompt, turn.assistant.user.text); + } +} + +fn readSessionDurableBytes( + alloc: Allocator, + store: Store, + id: []const u8, +) ![2][]u8 { + const events = try readFixtureFile( + alloc, + store, + id, + "events.jsonl", + 1024 * 1024, + ); + errdefer alloc.free(events); + const manifest = try readFixtureFile( + alloc, + store, + id, + "session.json", + session_projection.manifest_max_bytes, + ); + return .{ events, manifest }; +} + +test "fork copies a turn prefix into a new session and leaves the source unchanged" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const source_id = "fork-prefix-source"; + try writeForkSourceFixture(alloc, ctx.store, source_id, ctx.workspace, 5, 1); + + const before = try readSessionDurableBytes(alloc, ctx.store, source_id); + defer alloc.free(before[0]); + defer alloc.free(before[1]); + + var result = try ctx.store.forkSessionCopy(alloc, source_id, 3, .{}); + defer result.deinit(alloc); + try std.testing.expectEqualStrings(source_id, result.source_session_id); + try std.testing.expect(!std.mem.eql( + u8, + source_id, + result.forked_session_id, + )); + try std.testing.expectEqual(@as(usize, 3), result.history_len); + try std.testing.expectEqual(@as(usize, 5), result.source_history_len); + try std.testing.expectEqual( + store_types.SessionForkStatus.forked, + result.status, + ); + + const after = try readSessionDurableBytes(alloc, ctx.store, source_id); + defer alloc.free(after[0]); + defer alloc.free(after[1]); + try std.testing.expectEqualSlices(u8, before[0], after[0]); + try std.testing.expectEqualSlices(u8, before[1], after[1]); + + var forked = try ctx.store.loadReadOnly(alloc, result.forked_session_id); + defer forked.deinit(alloc); + try expectHistoryPrompts(forked, &.{ "turn-0", "turn-1", "turn-2" }); + try std.testing.expectEqual(@as(usize, 1), forked.context_history_start); + try std.testing.expectEqual(@as(u64, 4321), forked.total_input_tokens); + try std.testing.expectEqual(@as(u64, 8765), forked.total_output_tokens); + try std.testing.expect(forked.recovery_checkpoint == null); + try std.testing.expectEqualStrings(ctx.workspace, forked.workspace_root); +} + +test "fork clamps a model context cursor that outruns the retained turns" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const source_id = "fork-cursor-source"; + try writeForkSourceFixture(alloc, ctx.store, source_id, ctx.workspace, 5, 4); + + var result = try ctx.store.forkSessionCopy(alloc, source_id, 2, .{}); + defer result.deinit(alloc); + + var forked = try ctx.store.loadReadOnly(alloc, result.forked_session_id); + defer forked.deinit(alloc); + try std.testing.expectEqual(@as(usize, 2), forked.history.len); + try std.testing.expectEqual(@as(usize, 2), forked.context_history_start); +} + +test "fork rejects turn counts outside the source history" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const source_id = "fork-range-source"; + try writeForkSourceFixture(alloc, ctx.store, source_id, ctx.workspace, 3, 0); + try std.testing.expectError( + error.SessionTurnOutOfRange, + ctx.store.forkSessionCopy(alloc, source_id, 0, .{}), + ); + try std.testing.expectError( + error.SessionTurnOutOfRange, + ctx.store.forkSessionCopy(alloc, source_id, 4, .{}), + ); + + const empty_id = "fork-empty-source"; + try writeForkSourceFixture(alloc, ctx.store, empty_id, ctx.workspace, 0, 0); + try std.testing.expectError( + error.SessionTurnOutOfRange, + ctx.store.forkSessionCopy(alloc, empty_id, 1, .{}), + ); +} + +test "rewind drops trailing turns in place and keeps the session id" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const session_id = "rewind-in-place"; + try writeForkSourceFixture(alloc, ctx.store, session_id, ctx.workspace, 5, 4); + + var result = try ctx.store.rewindSession(alloc, session_id, 2, .{}); + defer result.deinit(alloc); + try std.testing.expectEqualStrings(session_id, result.session_id); + try std.testing.expectEqual(@as(usize, 2), result.history_len); + try std.testing.expectEqual(@as(usize, 3), result.removed_turn_count); + try std.testing.expectEqual( + store_types.SessionRewindStatus.rewound, + result.status, + ); + + var state = try ctx.store.loadReadOnly(alloc, session_id); + defer state.deinit(alloc); + try expectHistoryPrompts(state, &.{ "turn-0", "turn-1" }); + try std.testing.expectEqual(@as(usize, 2), state.context_history_start); +} + +test "rewind to an empty conversation is allowed" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const session_id = "rewind-to-empty"; + try writeForkSourceFixture(alloc, ctx.store, session_id, ctx.workspace, 3, 3); + + var result = try ctx.store.rewindSession(alloc, session_id, 0, .{}); + defer result.deinit(alloc); + try std.testing.expectEqual(@as(usize, 0), result.history_len); + try std.testing.expectEqual(@as(usize, 3), result.removed_turn_count); + + var state = try ctx.store.loadReadOnly(alloc, session_id); + defer state.deinit(alloc); + try std.testing.expectEqual(@as(usize, 0), state.history.len); + try std.testing.expectEqual(@as(usize, 0), state.context_history_start); +} + +test "rewind reports already_at_target without writing the log" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const session_id = "rewind-noop"; + try writeForkSourceFixture(alloc, ctx.store, session_id, ctx.workspace, 3, 0); + + const before = try readFixtureFile( + alloc, + ctx.store, + session_id, + "events.jsonl", + 1024 * 1024, + ); + defer alloc.free(before); + + var result = try ctx.store.rewindSession(alloc, session_id, 3, .{}); + defer result.deinit(alloc); + try std.testing.expectEqual( + store_types.SessionRewindStatus.already_at_target, + result.status, + ); + try std.testing.expectEqual(@as(usize, 3), result.history_len); + try std.testing.expectEqual(@as(usize, 0), result.removed_turn_count); + + const after = try readFixtureFile( + alloc, + ctx.store, + session_id, + "events.jsonl", + 1024 * 1024, + ); + defer alloc.free(after); + try std.testing.expectEqualSlices(u8, before, after); +} + +test "rewind rejects retaining more turns than the session holds" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const session_id = "rewind-range"; + try writeForkSourceFixture(alloc, ctx.store, session_id, ctx.workspace, 2, 0); + try std.testing.expectError( + error.SessionTurnOutOfRange, + ctx.store.rewindSession(alloc, session_id, 3, .{}), + ); +} + +test "rewind appends to the log and keeps the frames that hold the dropped turns" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const session_id = "rewind-log-retention"; + try writeForkSourceFixture(alloc, ctx.store, session_id, ctx.workspace, 3, 0); + + const before = try readFixtureFile( + alloc, + ctx.store, + session_id, + "events.jsonl", + 1024 * 1024, + ); + defer alloc.free(before); + + var result = try ctx.store.rewindSession(alloc, session_id, 1, .{}); + defer result.deinit(alloc); + + const after = try readFixtureFile( + alloc, + ctx.store, + session_id, + "events.jsonl", + 1024 * 1024, + ); + defer alloc.free(after); + try std.testing.expect(after.len > before.len); + try std.testing.expectEqualSlices(u8, before, after[0..before.len]); +} + +test "rewind refuses a session that is already open for writing" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const session_id = "rewind-busy"; + try writeForkSourceFixture(alloc, ctx.store, session_id, ctx.workspace, 3, 0); + + var holder = try ctx.store.resumeForWrite(alloc, session_id); + defer holder.deinit(alloc); + try std.testing.expectError( + error.SessionBusy, + ctx.store.rewindSession( + alloc, + session_id, + 1, + .{ .session_lock_deadline_ms = 0 }, + ), + ); +} + +fn testRecoveryCheckpoint() session_codec.RecoveryCheckpoint { + return .{ + .turn_id = 1, + .user = .{ .text = @constCast("prompt") }, + .assistant_source = @constCast(""), + .cause = .network_interrupted, + .action = .retrying_request, + .authority = .{ .provider = .gateway, .model = @constCast("test/model") }, + .requested_fast_mode = false, + .fast_mode = false, + .max_provider_attempts = 10, + .consumed_provider_attempts = 1, + }; +} + +test "rewind clears a paused response because it belongs to the dropped tail" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const session_id = "rewind-clears-checkpoint"; + try writeForkSourceFixture(alloc, ctx.store, session_id, ctx.workspace, 3, 0); + + var writable = try ctx.store.resumeForWrite(alloc, session_id); + _ = try writable.appendEvent( + alloc, + .{ .recovery_checkpoint_set = .{ .checkpoint = testRecoveryCheckpoint() } }, + 20, + .retry_expected_tail, + .{}, + ); + writable.deinit(alloc); + + var result = try ctx.store.rewindSession(alloc, session_id, 1, .{}); + defer result.deinit(alloc); + try std.testing.expectEqual(@as(usize, 2), result.removed_turn_count); + + var state = try ctx.store.loadReadOnly(alloc, session_id); + defer state.deinit(alloc); + try std.testing.expect(state.recovery_checkpoint == null); +} + +test "rewind leaves a paused response untouched when already at target" { + const alloc = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx = try initTempStore(alloc, &tmp); + defer ctx.deinit(alloc); + + const session_id = "rewind-noop-keeps-checkpoint"; + try writeForkSourceFixture(alloc, ctx.store, session_id, ctx.workspace, 3, 0); + + var writable = try ctx.store.resumeForWrite(alloc, session_id); + _ = try writable.appendEvent( + alloc, + .{ .recovery_checkpoint_set = .{ .checkpoint = testRecoveryCheckpoint() } }, + 20, + .retry_expected_tail, + .{}, + ); + writable.deinit(alloc); + + var result = try ctx.store.rewindSession(alloc, session_id, 3, .{}); + defer result.deinit(alloc); + try std.testing.expectEqual( + store_types.SessionRewindStatus.already_at_target, + result.status, + ); + + var state = try ctx.store.loadReadOnly(alloc, session_id); + defer state.deinit(alloc); + try std.testing.expect(state.recovery_checkpoint != null); + try std.testing.expectEqual(@as(u64, 1), state.recovery_checkpoint.?.turn_id); +} diff --git a/src/core/session/session_store_types.zig b/src/core/session/session_store_types.zig index 0fdb3fe22..d49dd0c11 100644 --- a/src/core/session/session_store_types.zig +++ b/src/core/session/session_store_types.zig @@ -218,6 +218,45 @@ pub const SessionRecoveryResult = struct { } }; +pub const SessionForkStatus = enum { + forked, + forked_with_unverified_artifacts, + indeterminate, +}; + +/// Result of branching a session at an absolute turn boundary. Owns both ids. +pub const SessionForkResult = struct { + source_session_id: []u8, + forked_session_id: []u8, + history_len: usize, + source_history_len: usize, + status: SessionForkStatus = .forked, + + pub fn deinit(self: *SessionForkResult, alloc: Allocator) void { + alloc.free(self.source_session_id); + alloc.free(self.forked_session_id); + self.* = undefined; + } +}; + +pub const SessionRewindStatus = enum { + rewound, + already_at_target, +}; + +/// Result of dropping trailing turns from a session in place. Owns its id. +pub const SessionRewindResult = struct { + session_id: []u8, + history_len: usize, + removed_turn_count: usize, + status: SessionRewindStatus = .rewound, + + pub fn deinit(self: *SessionRewindResult, alloc: Allocator) void { + alloc.free(self.session_id); + self.* = undefined; + } +}; + /// One class of integrity problem `doctor` can report for a session. pub const DoctorIssueKind = enum { authority_less_creation_orphan, diff --git a/src/core/slash_commands/command_router.zig b/src/core/slash_commands/command_router.zig index befe58ed6..81dab4049 100644 --- a/src/core/slash_commands/command_router.zig +++ b/src/core/slash_commands/command_router.zig @@ -9,9 +9,11 @@ pub const ParsedCommand = union(enum) { clear_screen, new_session, reset_session, - resume_session, + resume_session: []const u8, continue_recovery, rename_session: []const u8, + fork_session: []const u8, + rewind_session: []const u8, help, login, logout: []const u8, @@ -53,7 +55,7 @@ pub const CommandHandlers = struct { clear_screen: *const fn (ctx: *anyopaque) anyerror!void, new_session: *const fn (ctx: *anyopaque) anyerror!void, reset_session: *const fn (ctx: *anyopaque) anyerror!void, - resume_session: *const fn (ctx: *anyopaque) anyerror!void, + resume_session: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void, continue_recovery: *const fn (ctx: *anyopaque) anyerror!void, show_help: *const fn (ctx: *anyopaque) anyerror!void, login: *const fn (ctx: *anyopaque) anyerror!void, @@ -85,6 +87,8 @@ pub const CommandHandlers = struct { toggle_fast: *const fn (ctx: *anyopaque) anyerror!void, handle_statusline: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void, rename_session: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void, + fork_session: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void, + rewind_session: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void, handle_notifications: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void, handle_workspace: *const fn (ctx: *anyopaque, rest: []const u8) anyerror!void, show_version: *const fn (ctx: *anyopaque) anyerror!void, @@ -101,9 +105,11 @@ fn parsedCommand(kind: SlashKind, payload: []const u8) ParsedCommand { .clear_screen => .clear_screen, .new_session => .new_session, .reset_session => .reset_session, - .resume_session => .resume_session, + .resume_session => .{ .resume_session = payload }, .continue_recovery => .continue_recovery, .rename_session => .{ .rename_session = payload }, + .fork_session => .{ .fork_session = payload }, + .rewind_session => .{ .rewind_session = payload }, .help => .help, .login => .login, .logout => .{ .logout = payload }, @@ -153,14 +159,24 @@ pub fn parse(registry: SlashRegistry, cmd: []const u8) ParsedCommand { } pub fn route(registry: SlashRegistry, handlers: *const CommandHandlers, cmd: []const u8) !void { - switch (parse(registry, cmd)) { + return dispatch(handlers, parse(registry, cmd), cmd); +} + +pub fn dispatch( + handlers: *const CommandHandlers, + parsed: ParsedCommand, + cmd: []const u8, +) !void { + switch (parsed) { .quit => try handlers.quit(handlers.ctx), .clear_screen => try handlers.clear_screen(handlers.ctx), .new_session => try handlers.new_session(handlers.ctx), .reset_session => try handlers.reset_session(handlers.ctx), - .resume_session => try handlers.resume_session(handlers.ctx), + .resume_session => |rest| try handlers.resume_session(handlers.ctx, rest), .continue_recovery => try handlers.continue_recovery(handlers.ctx), .rename_session => |rest| try handlers.rename_session(handlers.ctx, rest), + .fork_session => |rest| try handlers.fork_session(handlers.ctx, rest), + .rewind_session => |rest| try handlers.rewind_session(handlers.ctx, rest), .help => try handlers.show_help(handlers.ctx), .login => try handlers.login(handlers.ctx), .logout => |rest| try handlers.logout(handlers.ctx, rest), @@ -248,8 +264,15 @@ test "parse distinguishes new and reset lifecycle commands" { try std.testing.expectEqual(ParsedCommand.reset_session, parse(testSlashRegistry(), "/reset")); } -test "parse recognizes interactive resume" { - try std.testing.expectEqual(ParsedCommand.resume_session, parse(testSlashRegistry(), "/resume")); +test "parse recognizes interactive resume targets" { + for ([_]struct { input: []const u8, payload: []const u8 }{ + .{ .input = "/resume", .payload = "" }, + .{ .input = "/resume last", .payload = "last" }, + .{ .input = "/resume session-123", .payload = "session-123" }, + }) |case| switch (parse(testSlashRegistry(), case.input)) { + .resume_session => |payload| try std.testing.expectEqualStrings(case.payload, payload), + else => return error.TestExpectedResumeCommand, + }; } test "parse recognizes explicit recovery continuation" { @@ -447,8 +470,10 @@ fn recordCopy(ctx: *anyopaque) anyerror!void { testContext(ctx).called = "copy"; } -fn recordResumeSession(ctx: *anyopaque) anyerror!void { - testContext(ctx).called = "resume"; +fn recordResumeSession(ctx: *anyopaque, value: []const u8) anyerror!void { + const test_context = testContext(ctx); + test_context.called = "resume"; + test_context.payload = value; } fn recordContinueRecovery(ctx: *anyopaque) anyerror!void { @@ -497,7 +522,7 @@ fn testHandlers(ctx: *TestContext) CommandHandlers { .clear_screen = unexpectedNoPayload, .new_session = unexpectedNoPayload, .reset_session = unexpectedNoPayload, - .resume_session = unexpectedNoPayload, + .resume_session = unexpectedPayload, .continue_recovery = unexpectedNoPayload, .show_help = unexpectedNoPayload, .login = unexpectedNoPayload, @@ -529,6 +554,8 @@ fn testHandlers(ctx: *TestContext) CommandHandlers { .toggle_fast = unexpectedNoPayload, .handle_statusline = unexpectedPayload, .rename_session = unexpectedPayload, + .fork_session = unexpectedPayload, + .rewind_session = unexpectedPayload, .handle_notifications = unexpectedPayload, .handle_workspace = unexpectedPayload, .show_version = unexpectedNoPayload, @@ -555,6 +582,7 @@ test "route calls interactive resume handler" { try route(testSlashRegistry(), &handlers, "/resume"); try std.testing.expectEqualStrings("resume", ctx.called); + try std.testing.expectEqualStrings("", ctx.payload); } test "route calls explicit recovery continuation handler" { diff --git a/src/core/slash_commands/command_specs.zig b/src/core/slash_commands/command_specs.zig index 7880b6dba..05fa530c8 100644 --- a/src/core/slash_commands/command_specs.zig +++ b/src/core/slash_commands/command_specs.zig @@ -40,6 +40,8 @@ pub const SlashKind = enum { resume_session, continue_recovery, rename_session, + fork_session, + rewind_session, help, login, logout, @@ -1782,7 +1784,7 @@ test "slash completion categories follow canonical entries" { test "help catalog groups visible commands and searches all command metadata" { const registry = testSlashRegistry(); - try std.testing.expectEqual(@as(usize, 36), helpCatalogCount(registry, "")); + try std.testing.expectEqual(@as(usize, 38), helpCatalogCount(registry, "")); try std.testing.expectEqualStrings("/help", helpCatalogSpecAt(registry, "", 0).?.command); try std.testing.expectEqual(@as(usize, 5), helpCatalogCategoryCount(registry, "", .general)); try std.testing.expectEqual(@as(usize, 3), helpCatalogCount(registry, "appearance")); @@ -1931,9 +1933,9 @@ test "slash completion prefix normalizes leading whitespace and preserves argume test "slash completion prefix yields to no-argument command submission" { const registry = testSlashRegistry(); - try std.testing.expectEqualStrings("/resume", slashCompletionPrefix(registry, "/resume").?); - try std.testing.expect(slashCompletionPrefix(registry, "/resume ") == null); - try std.testing.expect(slashCompletionPrefix(registry, "\n\t/resume\nignored") == null); + try std.testing.expectEqualStrings("/continue", slashCompletionPrefix(registry, "/continue").?); + try std.testing.expect(slashCompletionPrefix(registry, "/continue ") == null); + try std.testing.expect(slashCompletionPrefix(registry, "\n\t/continue\nignored") == null); try std.testing.expect(slashCompletionPrefix(registry, "/exit\t") == null); } diff --git a/src/ui/footer/compact_command_menu_presentation.zig b/src/ui/footer/compact_command_menu_presentation.zig index f12371dda..ba2823c2f 100644 --- a/src/ui/footer/compact_command_menu_presentation.zig +++ b/src/ui/footer/compact_command_menu_presentation.zig @@ -27,6 +27,7 @@ const ChoiceView = struct { pub fn desiredRowCount(projection: CompactCommandMenuProjection, width: u16) u16 { const count: usize = switch (projection) { + .turn_picker => |picker| turnPickerRowCount(picker), .statusline => settings_row_offset + settings_catalog.statuslineChoiceCount(), .usage => |usage| usageDesiredRowCount(usage, width), .workspace => |workspace| workspace_pinned_rows + @@ -45,12 +46,94 @@ pub noinline fn composeCompactCommandMenuRow( const empty: std.ArrayList(u8) = .empty; if (width == 0 or row_index >= visible_rows) return empty; return switch (projection) { + .turn_picker => |picker| composeTurnPickerRow(alloc, picker, row_index, visible_rows, width), .statusline => composeSettingsRow(alloc, projection, row_index, visible_rows, width), .usage => |usage| composeUsageRow(alloc, usage, row_index, visible_rows, width), .workspace => |workspace| composeWorkspaceRow(alloc, workspace, row_index, visible_rows, width), }; } +fn turnPickerRowCount(picker: render_input.TurnPickerProjection) usize { + const total = picker.history_len + 1; + const shown = @min(total -| picker.window_start, picker.visible_rows); + const above: usize = if (picker.window_start > 0) 1 else 0; + const below: usize = if (picker.window_start + shown < total) 1 else 0; + return shown + above + below + 4; +} + +fn composeTurnPickerRow( + alloc: Allocator, + picker: render_input.TurnPickerProjection, + row_index: u16, + visible_rows: u16, + width: u16, +) !std.ArrayList(u8) { + const empty: std.ArrayList(u8) = .empty; + if (row_index == 0) return composeStyledRow(alloc, "Rewind", width, ui_render.selected_completion_style); + if (row_index == 1) return composeStyledRow(alloc, "Restore the conversation to the point before…", width, ui_render.dim_style); + + const total = picker.history_len + 1; + const shown = @min(total -| picker.window_start, picker.visible_rows); + const row: usize = row_index; + var next: usize = 2; + if (picker.window_start > 0) { + if (row == next) return composeCountRow(alloc, "↑ {d} more above", picker.window_start, width); + next += 1; + } + if (row >= next and row < next + shown) { + const index = picker.window_start + (row - next); + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(alloc); + try out.appendSlice(alloc, if (index == picker.cursor) ui_render.selected_completion_style else ui_render.dim_style); + if (index == picker.history_len) { + try row_text.appendClipped(alloc, &out, "(current)", width); + } else { + const text = switch (picker.history[index]) { + .assistant => |turn| turn.user.text, + .background_command => |turn| turn.user.text, + .interrupted => |turn| turn.user.text, + .compacted_summary => "[compacted summary]", + }; + const line_end = std.mem.findAny(u8, text, "\r\n") orelse text.len; + try row_text.appendClipped(alloc, &out, text[0..line_end], width); + } + try out.appendSlice(alloc, ui_render.reset_style); + return out; + } + next += shown; + if (picker.window_start + shown < total and row == next) { + return composeCountRow(alloc, "↓ {d} more below", total - (picker.window_start + shown), width); + } + if (row_index != visible_rows - 2) return empty; + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(alloc); + try out.appendSlice(alloc, ui_render.dim_style); + if (picker.cursor == picker.history_len) { + try row_text.appendClipped(alloc, &out, "No change.", width); + } else { + const effect = try std.fmt.allocPrint( + alloc, + "Drops {d} turns, keeps {d}. File changes are not reverted.", + .{ picker.history_len - picker.cursor, picker.cursor }, + ); + defer alloc.free(effect); + try row_text.appendClipped(alloc, &out, effect, width); + } + try out.appendSlice(alloc, ui_render.reset_style); + return out; +} + +fn composeCountRow(alloc: Allocator, comptime fmt: []const u8, count: usize, width: u16) !std.ArrayList(u8) { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(alloc); + try out.appendSlice(alloc, ui_render.dim_style); + const text = try std.fmt.allocPrint(alloc, fmt, .{count}); + defer alloc.free(text); + try row_text.appendClipped(alloc, &out, text, width); + try out.appendSlice(alloc, ui_render.reset_style); + return out; +} + fn composeSettingsRow( alloc: Allocator, projection: CompactCommandMenuProjection, @@ -61,7 +144,7 @@ fn composeSettingsRow( const empty: std.ArrayList(u8) = .empty; const statusline = switch (projection) { .statusline => |value| value, - .usage, .workspace => return empty, + .turn_picker, .usage, .workspace => return empty, }; const choice_count = settings_catalog.statuslineChoiceCount(); if (choice_count == 0) return empty; @@ -101,7 +184,7 @@ fn choiceView(projection: CompactCommandMenuProjection, choice_index: usize) ?Ch .selected = choice_index == statusline.selected_index % settings_catalog.statuslineChoiceCount(), }; }, - .usage, .workspace => null, + .turn_picker, .usage, .workspace => null, }; } diff --git a/src/ui/footer/input_presentation.zig b/src/ui/footer/input_presentation.zig index 63eb44ee1..f36251a18 100644 --- a/src/ui/footer/input_presentation.zig +++ b/src/ui/footer/input_presentation.zig @@ -708,6 +708,11 @@ pub fn composeCompactCommandMenuHintRow( menu: render_input.CompactCommandMenuProjection, ) !std.ArrayList(u8) { const variants = switch (menu) { + .turn_picker => [_][]const u8{ + "Enter to continue · Esc to cancel", + "Enter · Esc", + "Enter Esc", + }, .statusline => [_][]const u8{ "↑↓ Navigate ←→ Change Esc Close", "↑↓ Move ←→ Change Esc", diff --git a/src/ui/footer/render_input.zig b/src/ui/footer/render_input.zig index 9c8eb022b..d42d51703 100644 --- a/src/ui/footer/render_input.zig +++ b/src/ui/footer/render_input.zig @@ -213,6 +213,15 @@ pub const SessionMenuProjection = struct { } }; +pub const TurnPickerProjection = struct { + active: bool = false, + history: []const types.HistoryTurn = &.{}, + history_len: usize = 0, + cursor: usize = 0, + window_start: usize = 0, + visible_rows: usize = 8, +}; + pub const HelpMenuProjection = struct { active: bool = false, category: ?command_specs.SlashPresentationCategory = null, @@ -312,6 +321,7 @@ pub fn workspaceMenuProjection( } pub const CompactCommandMenuProjection = union(enum) { + turn_picker: TurnPickerProjection, statusline: StatuslineMenuProjection, usage: UsageMenuProjection, workspace: WorkspaceMenuProjection, @@ -439,6 +449,7 @@ pub const RenderContext = struct { settings_menu: SettingsMenuProjection = .{}, model_menu: ModelMenuProjection = .{}, session_menu: SessionMenuProjection = .{}, + turn_picker: TurnPickerProjection = .{}, statusline_menu: StatuslineMenuProjection = .{}, usage_menu: UsageMenuProjection = .{}, workspace_menu: WorkspaceMenuProjection = .{}, @@ -454,6 +465,7 @@ pub const RenderContext = struct { }; pub fn activeCompactCommandMenu(ctx: RenderContext) ?CompactCommandMenuProjection { + if (ctx.turn_picker.active) return .{ .turn_picker = ctx.turn_picker }; if (ctx.statusline_menu.active) return .{ .statusline = ctx.statusline_menu }; if (ctx.usage_menu.active) return .{ .usage = ctx.usage_menu }; if (ctx.workspace_menu.active) return .{ .workspace = ctx.workspace_menu }; diff --git a/src/ui/resize_tests.zig b/src/ui/resize_tests.zig index 78d0dbf5a..a2c22af83 100644 --- a/src/ui/resize_tests.zig +++ b/src/ui/resize_tests.zig @@ -5947,7 +5947,7 @@ test "slash main page renders header categories selection range and contextual c try renderTestFooter(&h, &input, &approval, &h.frame_redraw); try h.flush(); - try expectGridContains(&h, "Commands 36 · Type to filter"); + try expectGridContains(&h, "Commands 38 · Type to filter"); try expectGridContains(&h, "1–6"); try expectGridContains(&h, "/help"); try expectGridContains(&h, "General"); @@ -5968,7 +5968,7 @@ test "slash main page renders header categories selection range and contextual c try expectGridContains(&h, "ask"); try expectGridContains(&h, "test-model"); - try expectGridNotContains(&h, "Commands 36"); + try expectGridNotContains(&h, "Commands 38"); try expectGridNotContains(&h, "↑↓ Navigate"); } diff --git a/tests/e2e/ci-shard-weights.json b/tests/e2e/ci-shard-weights.json index 157ac392c..0d958a6d0 100644 --- a/tests/e2e/ci-shard-weights.json +++ b/tests/e2e/ci-shard-weights.json @@ -18,6 +18,7 @@ { "file": "oauth-keychain-migration.test.ts", "weight": 30 }, { "file": "permission-errors.test.ts", "weight": 1 }, { "file": "prompt-history.test.ts", "weight": 14 }, + { "file": "session-fork.test.ts", "weight": 2 }, { "file": "session-recovery.test.ts", "weight": 13 }, { "file": "terminal-host.test.ts", "weight": 273 }, { "file": "tmux-helpers.test.ts", "weight": 2 }, @@ -44,6 +45,7 @@ { "file": "tui-resize.test.ts", "weight": 187 }, { "file": "tui-resume-brutal.test.ts", "weight": 18 }, { "file": "tui-resume.test.ts", "weight": 289 }, + { "file": "tui-session-fork.test.ts", "weight": 10 }, { "file": "tui-slash-commands.test.ts", "weight": 5 }, { "file": "tui-slash-extra.test.ts", "weight": 4 }, { "file": "tui-slash-menu.test.ts", "weight": 88 }, diff --git a/tests/e2e/cli.test.ts b/tests/e2e/cli.test.ts index e221b5bb7..eafb7ec0d 100644 --- a/tests/e2e/cli.test.ts +++ b/tests/e2e/cli.test.ts @@ -350,7 +350,7 @@ With --prompt-permissions, JSON and quiet requests may prompt on stderr only whe ); test( - "fx session help documents inspect resume migrate and recover", + "fx session help documents inspect resume migrate recover fork and rewind", async () => { for (const args of [ ["session", "--help"], @@ -359,11 +359,13 @@ With --prompt-permissions, JSON and quiet requests may prompt on stderr only whe const r = await runFx(args); expect(r.code).toBe(0); expect(r.stderr).toBe(""); - expect(r.stdout).toContain("Inspect, resume, migrate, or recover saved sessions"); + expect(r.stdout).toContain("Inspect, resume, migrate, recover, fork, or rewind saved sessions"); expect(r.stdout).toContain("session <last|id>|--id <id>"); expect(r.stdout).toContain("session resume [last|<id>]"); expect(r.stdout).toContain("session migrate <id>|--id <id>"); expect(r.stdout).toContain("session recover <id>|--id <id>"); + expect(r.stdout).toContain("session fork <id>|--id <id> --at <turn>"); + expect(r.stdout).toContain("session rewind <id>|--id <id> --by <count>"); } }, TIMEOUT, diff --git a/tests/e2e/prompt-history.test.ts b/tests/e2e/prompt-history.test.ts index 8800079a0..806ac3a8a 100644 --- a/tests/e2e/prompt-history.test.ts +++ b/tests/e2e/prompt-history.test.ts @@ -117,7 +117,7 @@ describe.skipIf(!tmuxAvailable())("prompt history", () => { await session.sendText("PLAN10_PROMPT_HISTORY_SENTINEL"); await session.waitForText("HTTP 401", TIMEOUT); await session.sendText("/help"); - await session.waitForText("Commands 36", TIMEOUT); + await session.waitForText("Commands 38", TIMEOUT); await session.sendKeys("Escape"); await session.waitForPane((pane) => !pane.includes("Enter Open"), TIMEOUT); await session.sendText("/quit"); diff --git a/tests/e2e/session-fork.test.ts b/tests/e2e/session-fork.test.ts new file mode 100644 index 000000000..13a4115fa --- /dev/null +++ b/tests/e2e/session-fork.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, test } from "bun:test"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runFx } from "../evals/eval-helpers"; +import { fakeGatewayFinalText, startFakeGateway } from "./tmux-helpers"; + +const TIMEOUT = 60_000; + +function sessionFileSnapshot(sessionDir: string): Record<string, string> { + return Object.fromEntries( + readdirSync(sessionDir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => [ + entry.name, + readFileSync(join(sessionDir, entry.name)).toString("base64"), + ]), + ); +} + +function makeWorkspace(prefix: string) { + const root = mkdtempSync(join(tmpdir(), prefix)); + const home = join(root, "home"); + const workspace = join(root, "workspace"); + mkdirSync(home); + mkdirSync(workspace); + return { root, home, workspaceRoot: realpathSync(workspace) }; +} + +async function seedSession( + workspaceRoot: string, + home: string, + answers: string[], +): Promise<string> { + const gateway = startFakeGateway( + answers.map((answer) => fakeGatewayFinalText(answer)), + ); + try { + let sessionId = ""; + for (const [index, answer] of answers.entries()) { + const args = + index === 0 + ? ["ask", "--json", "--auto", `prompt for ${answer}`] + : [ + "ask", + "--json", + "--auto", + "--resume", + "last", + `prompt for ${answer}`, + ]; + const run = await runFx(args, { + cwd: workspaceRoot, + env: { + HOME: home, + AI_GATEWAY_API_KEY: "e2e-placeholder", + VERCEL_OIDC_TOKEN: "", + FX_GATEWAY_BASE_URL: gateway.baseUrl, + FX_GATEWAY_CHAT_URL: gateway.chatUrl, + }, + }); + expect(run.code).toBe(0); + sessionId = JSON.parse(run.stdout).session_id; + } + return sessionId; + } finally { + gateway.stop?.(); + } +} + +async function sessionDetail( + workspaceRoot: string, + home: string, + sessionId: string, +) { + const detail = await runFx(["session", "--id", sessionId, "--json"], { + cwd: workspaceRoot, + env: { HOME: home }, + }); + expect(detail.code).toBe(0); + return JSON.parse(detail.stdout); +} + +function promptTexts(detail: { history: { user: { text: string } }[] }) { + return detail.history.map((turn) => turn.user.text); +} + +describe("session fork", () => { + test( + "fork branches a turn prefix into a new session and leaves the source unchanged", + async () => { + const { root, home, workspaceRoot } = makeWorkspace("fx-session-fork-"); + try { + const sessionId = await seedSession(workspaceRoot, home, [ + "ONE", + "TWO", + "THREE", + "FOUR", + ]); + const sessionDir = join(home, ".fx", "sessions", sessionId); + const sourceBefore = sessionFileSnapshot(sessionDir); + + const fork = await runFx( + ["session", "fork", sessionId, "--at", "2"], + { cwd: workspaceRoot, env: { HOME: home } }, + ); + expect(fork.code).toBe(0); + expect(fork.stderr).toBe(""); + const forkedId = fork.stdout.match(/ to (\S+)/)![1]!; + expect(forkedId).not.toBe(sessionId); + expect(fork.stdout).toBe( + `[session fork] forked ${sessionId} to ${forkedId}\n` + + "history_turns: 2 of 4\n" + + `resume: fx --resume ${forkedId}\n`, + ); + + expect(sessionFileSnapshot(sessionDir)).toEqual(sourceBefore); + + const forkedDetail = await sessionDetail(workspaceRoot, home, forkedId); + expect(forkedDetail.history_len).toBe(2); + expect(promptTexts(forkedDetail)).toEqual([ + "prompt for ONE", + "prompt for TWO", + ]); + + const sourceDetail = await sessionDetail( + workspaceRoot, + home, + sessionId, + ); + expect(sourceDetail.history_len).toBe(4); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); + + test( + "a forked session resumes and continues from its branch point", + async () => { + const { root, home, workspaceRoot } = makeWorkspace( + "fx-session-fork-resume-", + ); + try { + const sessionId = await seedSession(workspaceRoot, home, [ + "ONE", + "TWO", + "THREE", + ]); + const fork = await runFx( + ["session", "fork", "--id", sessionId, "--at", "1", "--json"], + { cwd: workspaceRoot, env: { HOME: home } }, + ); + expect(fork.code).toBe(0); + const forkedId = JSON.parse(fork.stdout).forked_id; + + const gateway = startFakeGateway([ + fakeGatewayFinalText("BRANCH_CONTINUED"), + ]); + try { + const resumed = await runFx( + ["ask", "--json", "--auto", "--resume", forkedId, "keep going"], + { + cwd: workspaceRoot, + env: { + HOME: home, + AI_GATEWAY_API_KEY: "e2e-placeholder", + VERCEL_OIDC_TOKEN: "", + FX_GATEWAY_BASE_URL: gateway.baseUrl, + FX_GATEWAY_CHAT_URL: gateway.chatUrl, + }, + }, + ); + expect(resumed.code).toBe(0); + expect(JSON.parse(resumed.stdout).session_id).toBe(forkedId); + } finally { + gateway.stop?.(); + } + + const detail = await sessionDetail(workspaceRoot, home, forkedId); + expect(promptTexts(detail)).toEqual(["prompt for ONE", "keep going"]); + expect( + (await sessionDetail(workspaceRoot, home, sessionId)).history_len, + ).toBe(3); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); + + test( + "fork --json reports both turn counts", + async () => { + const { root, home, workspaceRoot } = makeWorkspace( + "fx-session-fork-json-", + ); + try { + const sessionId = await seedSession(workspaceRoot, home, [ + "ONE", + "TWO", + "THREE", + ]); + const fork = await runFx( + ["session", "fork", sessionId, "--at", "2", "--json"], + { cwd: workspaceRoot, env: { HOME: home } }, + ); + expect(fork.code).toBe(0); + expect(fork.stderr).toBe(""); + const payload = JSON.parse(fork.stdout); + expect(payload.kind).toBe("session_fork"); + expect(payload.source_id).toBe(sessionId); + expect(payload.forked_id).not.toBe(sessionId); + expect(payload.status).toBe("forked"); + expect(payload.history_turns).toBe(2); + expect(payload.source_history_turns).toBe(3); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); + + test( + "fork rejects a turn past the end of the session in text and json", + async () => { + const { root, home, workspaceRoot } = makeWorkspace( + "fx-session-fork-range-", + ); + try { + const sessionId = await seedSession(workspaceRoot, home, [ + "ONE", + "TWO", + ]); + + const text = await runFx( + ["session", "fork", sessionId, "--at", "12"], + { cwd: workspaceRoot, env: { HOME: home } }, + ); + expect(text.code).toBe(1); + expect(text.stdout).toBe(""); + expect(text.stderr).toBe( + "fx session: turn 12 is out of range; session has 2 turns\n", + ); + + const json = await runFx( + ["session", "fork", sessionId, "--at", "12", "--json"], + { cwd: workspaceRoot, env: { HOME: home } }, + ); + expect(json.code).toBe(1); + expect(json.stderr).toBe(""); + const payload = JSON.parse(json.stdout); + expect(payload.kind).toBe("session"); + expect(payload.code).toBe("SessionTurnOutOfRange"); + expect(payload.error).toBe( + "turn 12 is out of range; session has 2 turns", + ); + + expect( + (await sessionDetail(workspaceRoot, home, sessionId)).history_len, + ).toBe(2); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); +}); + +describe("session rewind", () => { + test( + "rewind drops the last turns in place and keeps the session id", + async () => { + const { root, home, workspaceRoot } = makeWorkspace("fx-session-rewind-"); + try { + const sessionId = await seedSession(workspaceRoot, home, [ + "ONE", + "TWO", + "THREE", + ]); + + const rewind = await runFx( + ["session", "rewind", sessionId, "--by", "2"], + { cwd: workspaceRoot, env: { HOME: home } }, + ); + expect(rewind.code).toBe(0); + expect(rewind.stderr).toBe(""); + expect(rewind.stdout).toBe( + `[session rewind] ${sessionId}\n` + + "history_turns: 1\n" + + "removed_turns: 2\n", + ); + + const detail = await sessionDetail(workspaceRoot, home, sessionId); + expect(detail.id).toBe(sessionId); + expect(promptTexts(detail)).toEqual(["prompt for ONE"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); + + test( + "rewind --json reports the removed turn count and a no-op rewind", + async () => { + const { root, home, workspaceRoot } = makeWorkspace( + "fx-session-rewind-json-", + ); + try { + const sessionId = await seedSession(workspaceRoot, home, [ + "ONE", + "TWO", + "THREE", + ]); + + const rewind = await runFx( + ["session", "rewind", "--id", sessionId, "--by", "1", "--json"], + { cwd: workspaceRoot, env: { HOME: home } }, + ); + expect(rewind.code).toBe(0); + expect(rewind.stderr).toBe(""); + expect(JSON.parse(rewind.stdout)).toEqual({ + kind: "session_rewind", + id: sessionId, + status: "rewound", + history_turns: 2, + removed_turns: 1, + }); + + const noop = await runFx( + ["session", "rewind", sessionId, "--by", "0", "--json"], + { cwd: workspaceRoot, env: { HOME: home } }, + ); + expect(noop.code).toBe(0); + expect(JSON.parse(noop.stdout)).toEqual({ + kind: "session_rewind", + id: sessionId, + status: "already_at_target", + history_turns: 2, + removed_turns: 0, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); + + test( + "rewind rejects dropping more turns than the session holds", + async () => { + const { root, home, workspaceRoot } = makeWorkspace( + "fx-session-rewind-range-", + ); + try { + const sessionId = await seedSession(workspaceRoot, home, [ + "ONE", + "TWO", + ]); + + const text = await runFx( + ["session", "rewind", sessionId, "--by", "9"], + { cwd: workspaceRoot, env: { HOME: home } }, + ); + expect(text.code).toBe(1); + expect(text.stdout).toBe(""); + expect(text.stderr).toBe( + "fx session: cannot rewind 9 turns; session has 2 turns\n", + ); + + const json = await runFx( + ["session", "rewind", sessionId, "--by", "9", "--json"], + { cwd: workspaceRoot, env: { HOME: home } }, + ); + expect(json.code).toBe(1); + expect(json.stderr).toBe(""); + expect(JSON.parse(json.stdout).code).toBe("SessionTurnOutOfRange"); + + expect( + (await sessionDetail(workspaceRoot, home, sessionId)).history_len, + ).toBe(2); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + TIMEOUT, + ); +}); diff --git a/tests/e2e/tui-gateway-stream-lifecycle.test.ts b/tests/e2e/tui-gateway-stream-lifecycle.test.ts index caca162d1..c8a6fc3aa 100644 --- a/tests/e2e/tui-gateway-stream-lifecycle.test.ts +++ b/tests/e2e/tui-gateway-stream-lifecycle.test.ts @@ -1308,7 +1308,7 @@ async function runCanonicalLifecycleFixture( reachedFinal = settled.matched; if (reachedFinal) { await session.sendText("/help"); - const help = await waitForPaneOrDone(session, "Commands 36", donePath); + const help = await waitForPaneOrDone(session, "Commands 38", donePath); helpVisible = help.matched; requestCountAfterHelp = queuedGateway.requests.length; if (helpVisible) { @@ -7426,7 +7426,7 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { expect(gateway.requestCount()).toBe(1); await session.sendText("/help"); - await session.waitForText("Commands 36", TIMEOUT); + await session.waitForText("Commands 38", TIMEOUT); expect(gateway.requestCount()).toBe(1); await session.sendKeys("Escape"); }, diff --git a/tests/e2e/tui-input-navigation.test.ts b/tests/e2e/tui-input-navigation.test.ts index fa50756c6..0845ea398 100644 --- a/tests/e2e/tui-input-navigation.test.ts +++ b/tests/e2e/tui-input-navigation.test.ts @@ -322,7 +322,7 @@ tmuxTest( await waitForExactComposerRow(active, "┃ /"); await active.sendKeys("Enter"); - await active.waitForText("Commands 36", READY_TIMEOUT); + await active.waitForText("Commands 38", READY_TIMEOUT); await active.sendKeys("Escape"); await active.waitForPane( (pane) => hasEmptyComposer(pane) && !pane.includes("Enter Open"), @@ -1711,7 +1711,7 @@ tmuxTest( READY_TIMEOUT, ); await active.resizeWindow(80, 24, 300); - await active.waitForText("Commands 36", READY_TIMEOUT); + await active.waitForText("Commands 38", READY_TIMEOUT); expect(gateway?.requests).toHaveLength(0); expectCleanStderr(); }, diff --git a/tests/e2e/tui-render-stress.test.ts b/tests/e2e/tui-render-stress.test.ts index affbaecab..393863287 100644 --- a/tests/e2e/tui-render-stress.test.ts +++ b/tests/e2e/tui-render-stress.test.ts @@ -114,7 +114,7 @@ describe.skipIf(SKIP)("tui: render stress", () => { await session.sendKeys("C-u"); await session.sendText("/help"); - await session.waitForText("Commands 36", 5_000); + await session.waitForText("Commands 38", 5_000); await session.sendKeys("Escape"); await session.waitForPane((pane) => !pane.includes("Enter Open"), 5_000); await session.sendText("/status"); diff --git a/tests/e2e/tui-resize.test.ts b/tests/e2e/tui-resize.test.ts index 89074558c..51883eee5 100644 --- a/tests/e2e/tui-resize.test.ts +++ b/tests/e2e/tui-resize.test.ts @@ -2449,7 +2449,7 @@ describe.skipIf(SKIP)("tui: resize", () => { await session.waitForText("/help", 10_000); await waitForSelectedSlashLabel(session, "/help"); const shrinkStage = await session.captureFullScrollback(); - expect(shrinkStage).toContain("Commands 36 · Type to filter"); + expect(shrinkStage).toContain("Commands 38 · Type to filter"); expect(shrinkStage).toContain("1–4"); writeFileSync(join(root, "scrollback-after-shrink.txt"), shrinkStage); @@ -3426,11 +3426,11 @@ describe.skipIf(SKIP)("tui: resize", () => { async () => { session = await launchAt(120, 40); await session.sendText("/help"); - await session.waitForText("Commands 36", 5_000); + await session.waitForText("Commands 38", 5_000); await session.resizeWindow(76, 24, 400); const grid = await session.capturePaneGrid(); - expect(grid.join("\n")).toContain("Commands 36"); + expect(grid.join("\n")).toContain("Commands 38"); expect(findInlineHelpPicker(grid)).not.toBeNull(); await session.sendKeys("Escape"); @@ -3448,7 +3448,7 @@ describe.skipIf(SKIP)("tui: resize", () => { async () => { session = await launchAt(120, 40); await session.sendText("/help"); - await session.waitForText("Commands 36", 5_000); + await session.waitForText("Commands 38", 5_000); const captureScrollback = () => execSync(`tmux capture-pane -t ${session!.name} -p -S -`, { @@ -3456,7 +3456,7 @@ describe.skipIf(SKIP)("tui: resize", () => { stdio: "pipe", }); const expectHelpCatalog = (grid: string[]) => { - expect(grid.join("\n")).toContain("Commands 36"); + expect(grid.join("\n")).toContain("Commands 38"); expect(findInlineHelpPicker(grid)).not.toBeNull(); }; @@ -3474,7 +3474,7 @@ describe.skipIf(SKIP)("tui: resize", () => { const restored = captureScrollback(); expect(restored.match(/𝒇x v\d+\.\d+\.\d+\b/g)).toHaveLength(1); expect(restored.match(/Run \/help for commands/g)).toHaveLength(1); - expect(restored).not.toContain("Commands 36"); + expect(restored).not.toContain("Commands 38"); expect(findFooter(await session.capturePaneGrid())).not.toBeNull(); }, TIMEOUT, @@ -3988,7 +3988,7 @@ describe.skipIf(SKIP)("tui: resize", () => { expect(await session.captureFullScrollback()).toContain(marker); await session.sendText("/help"); - await session.waitForText("Commands 36", 5_000); + await session.waitForText("Commands 38", 5_000); await session.resizeWindow(84, 28, 500); const catalog = await session.capturePaneGrid(); @@ -4002,7 +4002,7 @@ describe.skipIf(SKIP)("tui: resize", () => { ); const scrollback = await session.captureFullScrollback(); expect(scrollback).not.toContain(marker); - expect(scrollback).not.toContain("Commands 36"); + expect(scrollback).not.toContain("Commands 38"); const finalGrid = await session.capturePaneGrid(); expect(findFooter(finalGrid), finalGrid.join("\n")).not.toBeNull(); diff --git a/tests/e2e/tui-session-fork.test.ts b/tests/e2e/tui-session-fork.test.ts new file mode 100644 index 000000000..c98c655d6 --- /dev/null +++ b/tests/e2e/tui-session-fork.test.ts @@ -0,0 +1,426 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FX_BIN, runFx } from "../evals/eval-helpers"; +import { + TmuxSession, + fakeGatewayFinalText, + heldFakeGatewayFinalText, + startFakeGateway, +} from "./tmux-helpers"; + +const TIMEOUT = 90_000; +const STEP_TIMEOUT = 20_000; +const MODEL = "openai/gpt-5"; + +type Gateway = ReturnType<typeof startFakeGateway>; + +let session: TmuxSession | null = null; +let gateway: Gateway | null = null; +const workDirs: string[] = []; + +afterEach(async () => { + if (session) { + await session.kill(); + session = null; + } + gateway?.stop(); + gateway = null; + for (const dir of workDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeWorkspace(prefix: string) { + const workDir = mkdtempSync(join(tmpdir(), prefix)); + workDirs.push(workDir); + const home = join(workDir, "home"); + const workspace = join(workDir, "workspace"); + mkdirSync(join(home, ".fx"), { recursive: true }); + mkdirSync(workspace, { recursive: true }); + return { workDir, home, workspace, stderrPath: join(workDir, "stderr.log") }; +} + +function gatewayEnvironment(home: string) { + if (!gateway) throw new Error("fake gateway not started"); + return { + HOME: home, + AI_GATEWAY_API_KEY: "fx-session-fork-e2e-key", + VERCEL_OIDC_TOKEN: undefined, + FX_GATEWAY_BASE_URL: gateway.baseUrl, + FX_GATEWAY_CHAT_URL: gateway.chatUrl, + FX_MODEL: MODEL, + FX_AUTO_UPGRADE: "0", + NO_COLOR: "1", + }; +} + +// Starts a shell whose model answers each prompt with the matching reply. +async function startShell(replies: string[], prefix: string) { + const workspace = makeWorkspace(prefix); + gateway = startFakeGateway(replies.map((text) => fakeGatewayFinalText(text))); + session = await TmuxSession.create({ + cmd: FX_BIN, + cwd: workspace.workspace, + env: gatewayEnvironment(workspace.home), + stderrPath: workspace.stderrPath, + width: 120, + height: 40, + isolated: true, + }); + await session.waitForComposer(STEP_TIMEOUT); + return workspace; +} + +async function ask(prompt: string, reply: string) { + await session!.sendText(prompt); + await session!.waitForText(reply, STEP_TIMEOUT); +} + +// Session ids wrap across pane rows, so compare against the pane with every +// space and line break removed. +async function flatPane(): Promise<string> { + return (await session!.capturePaneGrid()).join("").replace(/\s+/g, ""); +} + +async function savedSessions(home: string, workspace: string) { + const listed = await runFx(["sessions", "--json"], { + cwd: workspace, + env: { HOME: home }, + }); + expect(listed.code).toBe(0); + const ids: string[] = JSON.parse(listed.stdout).sessions.map( + (entry: { id: string }) => entry.id, + ); + const details = []; + for (const id of ids) { + const detail = await runFx(["session", "--id", id, "--json"], { + cwd: workspace, + env: { HOME: home }, + }); + expect(detail.code).toBe(0); + const parsed = JSON.parse(detail.stdout); + details.push({ + id: parsed.id as string, + prompts: parsed.history.map( + (turn: { user: { text: string } }) => turn.user.text, + ) as string[], + }); + } + return details; +} + +async function quitShell(stderrPath: string) { + await session!.sendText("/quit"); + expect(await session!.waitForSessionEnd(STEP_TIMEOUT)).toBe(true); + await session!.kill(); + session = null; + expect(readFileSync(stderrPath, "utf8")).toBe(""); +} + +describe("interactive session fork and rewind", () => { + test( + "bare /fork branches the full session and leaves the source unchanged", + async () => { + const { home, workspace, stderrPath } = await startShell( + ["REPLY_ONE", "SOURCE_REPLY"], + "fx-tui-fork-usage-", + ); + await ask("first prompt", "REPLY_ONE"); + + const before = await savedSessions(home, workspace); + expect(before).toHaveLength(1); + const sourceId = before[0]!.id; + + await session!.sendText("/fork"); + await session!.waitForText("You are now in the branch", STEP_TIMEOUT); + + const after = await savedSessions(home, workspace); + expect(after).toHaveLength(2); + const branch = after.find((entry) => entry.id !== sourceId)!; + expect(branch.prompts).toEqual(["first prompt"]); + expect(after.find((entry) => entry.id === sourceId)!.prompts).toEqual([ + "first prompt", + ]); + const pane = await flatPane(); + expect(pane).toContain(sourceId); + expect(pane).toContain(branch.id); + + await session!.sendText(`/resume ${sourceId}`); + await session!.waitForText("Session resumed", STEP_TIMEOUT); + await ask("source-only prompt", "SOURCE_REPLY"); + + const resumed = await savedSessions(home, workspace); + expect(resumed.find((entry) => entry.id === sourceId)!.prompts).toEqual([ + "first prompt", + "source-only prompt", + ]); + expect(resumed.find((entry) => entry.id === branch.id)!.prompts).toEqual([ + "first prompt", + ]); + + await quitShell(stderrPath); + const saved = await savedSessions(home, workspace); + expect(saved).toHaveLength(2); + expect(saved.find((entry) => entry.id === sourceId)!.prompts).toEqual([ + "first prompt", + "source-only prompt", + ]); + expect(saved.find((entry) => entry.id === branch.id)!.prompts).toEqual([ + "first prompt", + ]); + }, + TIMEOUT, + ); + + test( + "/rewind drops turns only when the identical request is repeated", + async () => { + const { home, workspace, stderrPath } = await startShell( + ["REPLY_ONE", "REPLY_TWO", "REPLY_THREE"], + "fx-tui-rewind-confirm-", + ); + await ask("first prompt", "REPLY_ONE"); + await ask("second prompt", "REPLY_TWO"); + await ask("third prompt", "REPLY_THREE"); + + await session!.sendText("/rewind 1"); + const armed = await session!.waitForText( + "Run /rewind 1 again to confirm", + STEP_TIMEOUT, + ); + expect(armed).toContain("File changes are not reverted"); + + // An intervening command cancels the arming, so the next request has to + // ask again rather than executing. + await session!.sendText("/version"); + await session!.waitForText("Version:", STEP_TIMEOUT); + await session!.sendText("/rewind 1"); + await session!.waitForText("Run /rewind 1 again to confirm", STEP_TIMEOUT); + + await session!.sendText("/rewind 1"); + const done = await session!.waitForText( + "Rewound 1 turn; 2 turns left", + STEP_TIMEOUT, + ); + expect(done).toContain("File changes were not reverted"); + await session!.waitForPane( + (pane) => !pane.includes("third prompt"), + STEP_TIMEOUT, + ); + + await quitShell(stderrPath); + const saved = await savedSessions(home, workspace); + expect(saved).toHaveLength(1); + expect(saved[0]!.prompts).toEqual(["first prompt", "second prompt"]); + }, + TIMEOUT, + ); + + test( + "bare /rewind selects a turn and restores its prompt to the composer", + async () => { + const { home, workspace, stderrPath } = await startShell( + ["REPLY_ONE", "REPLY_TWO", "REPLY_THREE"], + "fx-tui-rewind-picker-", + ); + await ask("first picker prompt", "REPLY_ONE"); + await ask("second picker prompt", "REPLY_TWO"); + await ask("third picker prompt", "REPLY_THREE"); + + await session!.sendText("/rewind"); + await session!.waitForText("(current)", STEP_TIMEOUT); + const opened = (await session!.capturePaneGrid()).join("\n"); + expect(opened).toContain("third picker prompt"); + expect(opened.indexOf("third picker prompt")).toBeLessThan( + opened.indexOf("(current)"), + ); + + await session!.sendKeys("Up"); + await session!.sendKeys("Up"); + await session!.waitForText("Drops 2 turns, keeps 1", STEP_TIMEOUT); + await session!.sendKeys("Enter"); + await session!.waitForPane( + (pane) => pane.includes("second picker prompt") && !pane.includes("No change."), + STEP_TIMEOUT, + ); + const rewound = (await session!.capturePaneGrid()).join("\n"); + expect(rewound).toContain("first picker prompt"); + expect(rewound.match(/second picker prompt/g)).toHaveLength(1); + expect(rewound).not.toContain("third picker prompt"); + + const saved = await savedSessions(home, workspace); + expect(saved).toHaveLength(1); + expect(saved[0]!.prompts).toEqual(["first picker prompt"]); + await session!.sendKeys("C-u"); + await quitShell(stderrPath); + }, + TIMEOUT, + ); + + test( + "Escape closes the rewind picker without changing history", + async () => { + const { home, workspace, stderrPath } = await startShell( + ["REPLY_ONE"], + "fx-tui-rewind-picker-cancel-", + ); + await ask("cancel picker prompt", "REPLY_ONE"); + await session!.sendText("/rewind"); + await session!.waitForText("(current)", STEP_TIMEOUT); + await session!.sendKeys("Escape"); + await session!.waitForPane( + (pane) => !pane.includes("Enter to continue") && !pane.includes("(current)"), + STEP_TIMEOUT, + ); + await session!.waitForComposer(STEP_TIMEOUT); + + const saved = await savedSessions(home, workspace); + expect(saved[0]!.prompts).toEqual(["cancel picker prompt"]); + await quitShell(stderrPath); + }, + TIMEOUT, + ); + + test( + "/rewind past the live history reports the real turn count and changes nothing", + async () => { + const { home, workspace, stderrPath } = await startShell( + ["REPLY_ONE", "REPLY_TWO"], + "fx-tui-rewind-range-", + ); + await ask("first prompt", "REPLY_ONE"); + await ask("second prompt", "REPLY_TWO"); + + await session!.sendText("/rewind 9"); + await session!.waitForText( + "cannot rewind 9 turns; session has 2 turns", + STEP_TIMEOUT, + ); + + await quitShell(stderrPath); + const saved = await savedSessions(home, workspace); + expect(saved).toHaveLength(1); + expect(saved[0]!.prompts).toEqual(["first prompt", "second prompt"]); + }, + TIMEOUT, + ); + + test( + "/fork reports both ids and continues in the branch", + async () => { + const { home, workspace, stderrPath } = await startShell( + ["REPLY_ONE", "REPLY_TWO", "REPLY_THREE", "REPLY_BRANCH"], + "fx-tui-fork-branch-", + ); + await ask("first prompt", "REPLY_ONE"); + await ask("second prompt", "REPLY_TWO"); + await ask("third prompt", "REPLY_THREE"); + + const before = await savedSessions(home, workspace); + expect(before).toHaveLength(1); + const sourceId = before[0]!.id; + + await session!.sendText("/fork 2"); + await session!.waitForText("You are now in the branch", STEP_TIMEOUT); + + const after = await savedSessions(home, workspace); + expect(after).toHaveLength(2); + const branch = after.find((entry) => entry.id !== sourceId)!; + expect(branch.prompts).toEqual(["first prompt", "second prompt"]); + + const pane = await flatPane(); + expect(pane).toContain(sourceId); + expect(pane).toContain(branch.id); + expect(pane).toContain(`atturn2into`); + + // The next prompt has to land in the branch, not in the source. + await ask("branch prompt", "REPLY_BRANCH"); + await quitShell(stderrPath); + + const final = await savedSessions(home, workspace); + expect(final.find((entry) => entry.id === sourceId)!.prompts).toEqual([ + "first prompt", + "second prompt", + "third prompt", + ]); + expect(final.find((entry) => entry.id === branch.id)!.prompts).toEqual([ + "first prompt", + "second prompt", + "branch prompt", + ]); + }, + TIMEOUT, + ); + + test( + "both commands refuse while a response is still streaming", + async () => { + const workspace = makeWorkspace("fx-tui-fork-streaming-"); + const held = heldFakeGatewayFinalText(); + gateway = startFakeGateway([ + fakeGatewayFinalText("REPLY_ONE"), + held.response, + ]); + session = await TmuxSession.create({ + cmd: FX_BIN, + cwd: workspace.workspace, + env: gatewayEnvironment(workspace.home), + stderrPath: workspace.stderrPath, + width: 120, + height: 40, + isolated: true, + }); + await session.waitForComposer(STEP_TIMEOUT); + await ask("first prompt", "REPLY_ONE"); + + await session.sendText("second prompt"); + await session.waitForText("second prompt", STEP_TIMEOUT); + + await session.sendText("/fork 1"); + await session.waitForText( + "fork is unavailable until the response finishes", + STEP_TIMEOUT, + ); + await session.sendText("/rewind 1"); + await session.waitForText( + "rewind is unavailable until the response finishes", + STEP_TIMEOUT, + ); + + held.release("REPLY_TWO"); + await session.waitForText("REPLY_TWO", STEP_TIMEOUT); + + await quitShell(workspace.stderrPath); + const saved = await savedSessions(workspace.home, workspace.workspace); + expect(saved).toHaveLength(1); + expect(saved[0]!.prompts).toEqual(["first prompt", "second prompt"]); + }, + TIMEOUT, + ); + + test( + "/fork past the live history reports the real turn count and creates nothing", + async () => { + const { home, workspace, stderrPath } = await startShell( + ["REPLY_ONE", "REPLY_TWO"], + "fx-tui-fork-range-", + ); + await ask("first prompt", "REPLY_ONE"); + await ask("second prompt", "REPLY_TWO"); + + await session!.sendText("/fork 9"); + await session!.waitForText( + "turn 9 is out of range; session has 2 turns", + STEP_TIMEOUT, + ); + + await quitShell(stderrPath); + const saved = await savedSessions(home, workspace); + expect(saved).toHaveLength(1); + expect(saved[0]!.prompts).toEqual(["first prompt", "second prompt"]); + }, + TIMEOUT, + ); +}); diff --git a/tests/e2e/tui-slash-menu.test.ts b/tests/e2e/tui-slash-menu.test.ts index d265540bc..d3a6a6e64 100644 --- a/tests/e2e/tui-slash-menu.test.ts +++ b/tests/e2e/tui-slash-menu.test.ts @@ -1006,7 +1006,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { ).toBe(69); expect(closedComposerRow).toBe(73); await session.sendLiteralText("/"); - await session.waitForText("Commands 36", 5_000); + await session.waitForText("Commands 38", 5_000); const afterSlash = await capture("after-slash"); expect(visibleTranscriptTailRow(afterSlash)).toBe(60); expect(composerRow(afterSlash)).toBe(64); @@ -1452,10 +1452,12 @@ describe.skipIf(SKIP)("tui: slash menu", () => { ); await session.sendLiteralText("/resume "); pane = await session.waitForPane( - (current) => - composerContains(current, "/resume") && - !current.includes("resume-helper") && - !current.includes("Enter Use"), + (current) => composerContains(current, "/resume") && current.includes("resume-helper"), + 5_000, + ); + await session.sendKeys("Escape"); + pane = await session.waitForPane( + (current) => composerContains(current, "/resume") && !current.includes("Enter Use"), 5_000, ); expect(pane).not.toContain("no matching slash commands"); @@ -1531,7 +1533,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { await session.waitForComposer(10_000); await session.sendText("/help"); - let grid = await waitForHelpMenu(session, 36); + let grid = await waitForHelpMenu(session, 38); let pane = grid.join("\n"); expect(pane).toContain("𝒇x"); expect(pane).toContain("Run /help for commands"); @@ -1547,7 +1549,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { grid = await waitForHelpMenu(session, 5); expect(grid.join("\n")).toContain("[General]"); await session.sendKeys("BTab"); - grid = await waitForHelpMenu(session, 36); + grid = await waitForHelpMenu(session, 38); expect(grid.join("\n")).toContain("[All]"); await session.sendLiteralText("clipboard"); @@ -1558,11 +1560,11 @@ describe.skipIf(SKIP)("tui: slash menu", () => { expect(pane).not.toContain("/clear"); await session.sendKeys("C-u"); - await waitForHelpMenu(session, 36); + await waitForHelpMenu(session, 38); await session.sendKeys("Down"); await session.sendKeys("Enter"); pane = await session.waitForPane( - (current) => hasEmptyComposer(current) && !current.includes("Commands 36"), + (current) => hasEmptyComposer(current) && !current.includes("Commands 38"), 5_000, ); expect(composerContains(pane, "/clear")).toBe(false); @@ -1571,7 +1573,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { await session.sendKeys("C-u"); await session.sendText("/help"); - await waitForHelpMenu(session, 36); + await waitForHelpMenu(session, 38); await session.sendLiteralText("additional directories"); await waitForHelpMenu(session, 1); await session.sendKeys("Enter"); @@ -1588,7 +1590,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { await session.sendKeys("C-u"); await session.sendText("/help"); - await waitForHelpMenu(session, 36); + await waitForHelpMenu(session, 38); await session.sendLiteralText("no command can match this query"); await session.waitForText("No commands found.", 5_000); await session.sendKeys("Escape"); @@ -2604,7 +2606,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { expect(alternateCount("\x1b[?1049l")).toBe(leavesBeforeSkills); await session.sendText("/help"); - grid = await waitForHelpMenu(session, 36); + grid = await waitForHelpMenu(session, 38); expect(grid.join("\n")).toContain("Run /help for commands"); expect(alternateCount("\x1b[?1049h")).toBe(entersBeforeSkills); expect(alternateCount("\x1b[?1049l")).toBe(leavesBeforeSkills); @@ -3496,7 +3498,7 @@ describe.skipIf(SKIP)("tui: slash menu", () => { await session.waitForComposer(10_000); await session.sendLiteralText("/"); - await session.waitForText("Commands 36", 5_000); + await session.waitForText("Commands 38", 5_000); for (let i = 0; i < 5; i += 1) { await session.sendKeys("Down"); diff --git a/tests/e2e/tui-startup.test.ts b/tests/e2e/tui-startup.test.ts index a6d07e398..897227318 100644 --- a/tests/e2e/tui-startup.test.ts +++ b/tests/e2e/tui-startup.test.ts @@ -40,7 +40,7 @@ describe.skipIf(SKIP)("tui: startup and exit", () => { session = await TmuxSession.create(); await session.waitForComposer(10_000); await session.sendText("/help"); - const pane = await session.waitForText("Commands 36", 5_000); + const pane = await session.waitForText("Commands 38", 5_000); expect(pane).toContain("[All]"); expect(pane).toContain("Tab Category"); expect(pane).toContain("Enter Open");