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 e67ba3ada..c9bd41f89 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,17 @@ fx session rewind --by 2 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 7` branches at turn 7, names both the source ID and the new one, and leaves you in the branch. The source session keeps every turn it had. `/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 738964002..3e3250faf 100644 --- a/scripts/pgso/corpus.json +++ b/scripts/pgso/corpus.json @@ -114,6 +114,7 @@ "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 9a97cab8b..9e025912b 100644 --- a/src/builtins/commands.zig +++ b/src/builtins/commands.zig @@ -442,6 +442,8 @@ pub const slash_specs = [_]SlashSpec{ .{ .kind = .resume_session, .command = "/resume", .help_entry = "/resume", .completion_description = "resume a saved session", .presentation_category = .session }, .{ .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 }, @@ -542,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..6d1e35c14 100644 --- a/src/core/app/app_commands.zig +++ b/src/core/app/app_commands.zig @@ -339,8 +339,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 +389,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, @@ -683,6 +692,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 +3450,218 @@ 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 at_turn = parsedTurnCount(rest) orelse { + try writeTurnArgumentUsage(app, "/fork <turn>"); + 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(); + 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 requested = parsedTurnCount(rest) 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_session_runtime.zig b/src/core/app/app_session_runtime.zig index 39a19e729..9ddffaddf 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -60,6 +60,108 @@ 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()); +} + const LiveSessionTransitionEvent = union(enum) { request: BackgroundSessionPolicy, settle, @@ -1090,12 +1192,13 @@ 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, /// 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 != 20) { @compileError("update Persistence.initInto for the changed field set"); } } @@ -1119,6 +1222,7 @@ pub const Persistence = struct { storage.resume_view_admission = null; storage.resume_handoff_intent = .none; storage.pending_live_session_policy = null; + storage.rewind_gate = .idle; } pub fn deinit(self: *Persistence, alloc: Allocator) void { @@ -2756,6 +2860,223 @@ 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 => {}, + } + + app.session.truncateHistory(app.alloc, target.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); + } + return .{ .rewound = target }; + } + + 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/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_store.zig b/src/core/session/session_store.zig index e209e6065..2218d823e 100644 --- a/src/core/session/session_store.zig +++ b/src/core/session/session_store.zig @@ -4561,26 +4561,25 @@ const StagingPromotionStatus = enum { indeterminate, }; -/// Frees the turns after `retained_turns` and clamps the model-context cursor -/// so it can never point past the shortened history. fn truncateSessionHistory( alloc: Allocator, state: *session_codec.DurableSessionState, retained_turns: usize, ) !void { - std.debug.assert(retained_turns <= state.history.len); - state.context_history_start = @min( - state.context_history_start, - retained_turns, - ); - if (retained_turns == state.history.len) return; const dropped = state.history; - const retained: []session.HistoryTurn = if (retained_turns == 0) + 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]); - for (dropped[retained_turns..]) |turn| session.freeHistoryTurn(alloc, turn); - alloc.free(dropped); + session.dropHistoryTurnsAfter( + alloc, + dropped, + &state.context_history_start, + retained_turns, + ); + if (retained_turns != dropped.len) alloc.free(dropped); state.history = retained; } diff --git a/src/core/slash_commands/command_router.zig b/src/core/slash_commands/command_router.zig index befe58ed6..0d4511776 100644 --- a/src/core/slash_commands/command_router.zig +++ b/src/core/slash_commands/command_router.zig @@ -12,6 +12,8 @@ pub const ParsedCommand = union(enum) { resume_session, continue_recovery, rename_session: []const u8, + fork_session: []const u8, + rewind_session: []const u8, help, login, logout: []const u8, @@ -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, @@ -104,6 +108,8 @@ fn parsedCommand(kind: SlashKind, payload: []const u8) ParsedCommand { .resume_session => .resume_session, .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,7 +159,15 @@ 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), @@ -161,6 +175,8 @@ pub fn route(registry: SlashRegistry, handlers: *const CommandHandlers, cmd: []c .resume_session => try handlers.resume_session(handlers.ctx), .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), @@ -529,6 +545,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, diff --git a/src/core/slash_commands/command_specs.zig b/src/core/slash_commands/command_specs.zig index 7880b6dba..879095dc7 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")); 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 19d4444b6..0d958a6d0 100644 --- a/tests/e2e/ci-shard-weights.json +++ b/tests/e2e/ci-shard-weights.json @@ -45,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/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/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..3965d065d --- /dev/null +++ b/tests/e2e/tui-session-fork.test.ts @@ -0,0 +1,326 @@ +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( + "a bare /fork or /rewind names its argument instead of acting", + async () => { + const { home, workspace, stderrPath } = await startShell( + ["REPLY_ONE"], + "fx-tui-fork-usage-", + ); + await ask("first prompt", "REPLY_ONE"); + + await session!.sendText("/fork"); + await session!.waitForText("Use: /fork <turn>", STEP_TIMEOUT); + await session!.sendText("/rewind"); + await session!.waitForText("Use: /rewind <count>", STEP_TIMEOUT); + expect(await flatPane()).toContain("Run`fxsession"); + + await quitShell(stderrPath); + const saved = await savedSessions(home, workspace); + expect(saved).toHaveLength(1); + expect(saved[0]!.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 quitShell(stderrPath); + const saved = await savedSessions(home, workspace); + expect(saved).toHaveLength(1); + expect(saved[0]!.prompts).toEqual(["first prompt", "second prompt"]); + }, + 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..a171b697a 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); @@ -1531,7 +1531,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 +1547,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 +1558,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 +1571,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 +1588,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 +2604,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 +3496,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");