From 0b15b9b116e36628af8b4701651866010d6a69f3 Mon Sep 17 00:00:00 2001 From: Aarya2004 Date: Mon, 31 Aug 2026 23:47:29 -0700 Subject: [PATCH 01/20] Rename recovery staging helpers to staging helpers The staging lock, staging root, staged-session start, promotion, and discard helpers are generic. Session fork will call them too, so the `recovery` prefix would misdescribe them. The on-disk directory and lock file names are unchanged. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/session/session_store.zig | 74 +++++++++++++++--------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/src/core/session/session_store.zig b/src/core/session/session_store.zig index 18b667445..a3debbe6f 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 = @@ -657,7 +659,7 @@ pub const Store = struct { return loaded; } - fn startRecoveryStagedSession( + fn startStagedSession( self: Store, alloc: Allocator, staging_root: *session_log.Root, @@ -675,7 +677,7 @@ pub const Store = struct { return loaded; } - fn initRecoveryStagingRoot( + fn initStagingRoot( self: Store, alloc: Allocator, ) !session_log.Root { @@ -684,20 +686,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 +708,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 +728,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 +737,7 @@ pub const Store = struct { }; } - fn cleanupAbandonedRecoveryStages( + fn cleanupAbandonedStages( staging_root: *session_log.Root, ) !void { const staging = &(staging_root.sessions orelse @@ -749,18 +751,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 +781,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 +792,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 +804,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 +812,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 +820,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 +828,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 +836,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; @@ -3945,14 +3947,14 @@ pub const Store = struct { var initial = try recoveryInitialState(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 +3966,7 @@ pub const Store = struct { if (target_promoted) { target.deinit(alloc); } else { - const disposition = discardRecoveryStagedSession( + const disposition = discardStagedSession( &staging_root, alloc, &target, @@ -4038,7 +4040,7 @@ pub const Store = struct { @errorName(resolve_err), }, ); - const disposition = discardRecoveryStagedSession( + const disposition = discardStagedSession( &staging_root, alloc, &target, @@ -4055,7 +4057,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 +4078,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 +4093,7 @@ pub const Store = struct { } return error.SessionRecoveryIndeterminate; }; - const promotion = self.promoteRecoveryStagedSession( + const promotion = self.promoteStagedSession( &staging_root, recovered_id, ) catch |err| { @@ -4187,7 +4189,7 @@ pub const PristineDiscardDisposition = enum { indeterminate, }; -const RecoveryPromotionStatus = enum { +const StagingPromotionStatus = enum { promoted, indeterminate, }; @@ -9678,14 +9680,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, From c62953f4441726b31b10d03e579de954e0fe6f4a Mon Sep 17 00:00:00 2001 From: Aarya2004 Date: Mon, 31 Aug 2026 23:59:30 -0700 Subject: [PATCH 02/20] Add fork and rewind state replacement reasons A fork writes its history into a brand-new session, so its cache publication cannot be deferred. A rewind rewrites the same session in place and follows compaction. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/session/session_event.zig | 2 ++ src/core/session/session_log.zig | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) 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) From a1352458397e0b1a65c95c308df2304d7cf27864 Mon Sep 17 00:00:00 2001 From: Aarya2004 Date: Tue, 1 Sep 2026 00:56:43 -0700 Subject: [PATCH 03/20] Add durable session fork and rewind primitives `forkSessionCopy` stages a new session holding the first N turns of a healthy source, copies only the artifacts those turns reach, and commits the branch history as a `fork` state replacement. The source is opened under its writer lock and read through the read-only replay, so it is never rewritten. `rewindSession` truncates the same session in place under a `rewind` state replacement. Artifacts for the dropped turns are left on disk. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/session/session_store.zig | 731 ++++++++++++++++++++++- src/core/session/session_store_types.zig | 39 ++ 2 files changed, 768 insertions(+), 2 deletions(-) diff --git a/src/core/session/session_store.zig b/src/core/session/session_store.zig index a3debbe6f..8472001ff 100644 --- a/src/core/session/session_store.zig +++ b/src/core/session/session_store.zig @@ -146,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; @@ -3945,7 +3949,7 @@ 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.acquireStagingLock( options.session_lock_deadline_ms, @@ -4181,6 +4185,364 @@ 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. + 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); + 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 { @@ -4194,7 +4556,51 @@ const StagingPromotionStatus = enum { indeterminate, }; -fn recoveryInitialState( +/// 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) + &.{} + else + try alloc.dupe(session.HistoryTurn, dropped[0..retained_turns]); + for (dropped[retained_turns..]) |turn| session.freeHistoryTurn(alloc, turn); + 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 { @@ -13871,3 +14277,324 @@ 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 }, + ), + ); +} 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, From 330e492c62acafb6a0f58dd31b824a1d6219cb5c Mon Sep 17 00:00:00 2001 From: Aarya2004 Date: Tue, 1 Sep 2026 01:03:28 -0700 Subject: [PATCH 04/20] Render session fork and rewind snapshots Both verbs render text and JSON from one snapshot, following the session recovery contract. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/output/output_contracts.zig | 238 +++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) 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") }, From aac16391e5033010cebe4542953ea8c2025fbb0d Mon Sep 17 00:00:00 2001 From: Aarya2004 Date: Tue, 1 Sep 2026 01:06:59 -0700 Subject: [PATCH 05/20] Add fx session fork and fx session rewind `fork --at ` names an absolute boundary read straight off the `[turn N]` labels `fx session ` prints. `rewind --by ` names a relative one, which is how undoing the last turns is asked for. The CLI resolves both to the absolute retained-turn count the store takes, and reports an out-of-range request with the session's real turn count. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/builtins/commands.zig | 8 +- src/core/cli/cli_surface.zig | 375 +++++++++++++++++++++++++++++++++-- 2 files changed, 369 insertions(+), 14 deletions(-) diff --git a/src/builtins/commands.zig b/src/builtins/commands.zig index adcdaa9dd..9a97cab8b 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]" }, 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, From 82718e3c80331bf4c60cc6e079362aa9e0d1fbd5 Mon Sep 17 00:00:00 2001 From: Aarya2004 Date: Tue, 1 Sep 2026 01:16:38 -0700 Subject: [PATCH 06/20] Document session fork and rewind and cover them end to end The e2e suite drives the built binary against a fake gateway: it seeds a multi-turn session, forks it, resumes the branch, and checks the source directory is byte-identical afterward. Classified verification-only in the PGSO corpus, since branching and undo are deliberate rare operations that must stay correct without being made hot. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- README.md | 11 + scripts/pgso/corpus.json | 1 + tests/e2e/ci-shard-weights.json | 1 + tests/e2e/cli.test.ts | 6 +- tests/e2e/session-fork.test.ts | 398 ++++++++++++++++++++++++++++++++ 5 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/session-fork.test.ts diff --git a/README.md b/README.md index 37f120eb0..e67ba3ada 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,17 @@ fx session resume last fx session resume --id ``` +`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. + 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..738964002 100644 --- a/scripts/pgso/corpus.json +++ b/scripts/pgso/corpus.json @@ -113,6 +113,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-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/tests/e2e/ci-shard-weights.json b/tests/e2e/ci-shard-weights.json index 157ac392c..19d4444b6 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 }, 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 |--id "); expect(r.stdout).toContain("session resume [last|]"); expect(r.stdout).toContain("session migrate |--id "); expect(r.stdout).toContain("session recover |--id "); + expect(r.stdout).toContain("session fork |--id --at "); + expect(r.stdout).toContain("session rewind |--id --by "); } }, TIMEOUT, 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 { + 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 { + 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, + ); +}); From ac1feeef77566c5cf586554ceefd31775e10fc4a Mon Sep 17 00:00:00 2001 From: Aarya2004 Date: Tue, 1 Sep 2026 09:00:10 -0700 Subject: [PATCH 07/20] Clear the paused response when a rewind drops turns A recovery checkpoint describes an in-flight turn that sits past the last committed one, so any rewind that drops turns leaves it describing work the session no longer has. Keeping it let `--continue-recovery` resume a response for a turn that was removed. `forkSessionCopy` already cleared it. A no-op rewind still keeps it, because that path drops nothing and commits nothing. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/session/session_store.zig | 84 +++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/src/core/session/session_store.zig b/src/core/session/session_store.zig index 8472001ff..e209e6065 100644 --- a/src/core/session/session_store.zig +++ b/src/core/session/session_store.zig @@ -4459,7 +4459,8 @@ pub const Store = struct { /// 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. + /// 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, @@ -4488,6 +4489,10 @@ pub const Store = struct { 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, @@ -14598,3 +14603,80 @@ test "rewind refuses a session that is already open for writing" { ), ); } + +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); +} From b9ab6a0067cb6f72a47571addd77be4675661c1d Mon Sep 17 00:00:00 2001 From: Aarya2004 Date: Tue, 1 Sep 2026 02:31:16 -0700 Subject: [PATCH 08/20] Register /fork and /rewind slash commands The specs, the parsed variants, and the handler slots land first with stub bodies so the exhaustive switches and the registry-walking router test prove the wiring before any behavior exists. `route` now splits into `parse` plus `dispatch` so a caller can inspect the parsed command once instead of parsing it twice. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/builtins/commands.zig | 4 ++++ src/core/app/app_commands.zig | 22 +++++++++++++++++++ src/core/slash_commands/command_router.zig | 20 ++++++++++++++++- src/core/slash_commands/command_specs.zig | 4 +++- src/ui/resize_tests.zig | 4 ++-- tests/e2e/prompt-history.test.ts | 2 +- .../e2e/tui-gateway-stream-lifecycle.test.ts | 4 ++-- tests/e2e/tui-input-navigation.test.ts | 4 ++-- tests/e2e/tui-render-stress.test.ts | 2 +- tests/e2e/tui-resize.test.ts | 16 +++++++------- tests/e2e/tui-slash-menu.test.ts | 18 +++++++-------- tests/e2e/tui-startup.test.ts | 2 +- 12 files changed, 74 insertions(+), 28 deletions(-) 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..3b78251cb 100644 --- a/src/core/app/app_commands.zig +++ b/src/core/app/app_commands.zig @@ -382,6 +382,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 +685,26 @@ 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)); + _ = rest; + try app.writeDomainNotice(.{ + .topic = "session", + .tone = .neutral, + .body = "not implemented", + }, true); + } + + fn commandRewindSession(ctx: *anyopaque, rest: []const u8) !void { + const app: *App = @ptrCast(@alignCast(ctx)); + _ = rest; + try app.writeDomainNotice(.{ + .topic = "session", + .tone = .neutral, + .body = "not implemented", + }, true); + } + fn commandShowHelp(ctx: *anyopaque) !void { const app: *App = @ptrCast(@alignCast(ctx)); if (comptime @hasField(App, "skills")) app.skills.closeMenu(); 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/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-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"); From 87aa050801b20dfaad397cb54d01309a279de3a5 Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 02:58:37 -0700 Subject: [PATCH 09/20] Add the rewind confirmation gate A rewind runs only when the identical request is repeated. The gate arms on the whole target, so a turn arriving between the preview and the repeat re-arms rather than firing a rewind the user never saw described. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/app/app_session_runtime.zig | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 39a19e729..1c6b3103f 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -60,6 +60,92 @@ const BackgroundSessionPolicy = enum { stop_forget, }; +/// 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; + } +}; + +pub 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. +pub 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, From c79dc7e5192ac954de96b731b89ec031734514b9 Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 03:05:27 -0700 Subject: [PATCH 10/20] Share history truncation between the store and the live session `dropHistoryTurnsAfter` owns freeing the dropped turns and clamping the model-context cursor. The durable store keeps its slice reallocation and the live runtime shrinks its list, but neither repeats the free-and-clamp rule, so an in-place rewind cannot drift from the stored one. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/session/session.zig | 30 ++++++++++++++++++++++++++++++ src/core/session/session_store.zig | 21 ++++++++++----------- 2 files changed, 40 insertions(+), 11 deletions(-) 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; } From 1deb2084176a6ab3ba9795db393fa4e809fe173e Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 03:22:31 -0700 Subject: [PATCH 11/20] Add the /rewind slash command `/rewind <count>` drops trailing turns from the live session. The shell already holds the session writer lock, so the truncation happens in process and commits through the live replacement path instead of through `Store.rewindSession`, which would block on the lock this shell owns. Both the confirmation and the completion say that file changes are untouched, because the equivalent command in other agents restores files and this one does not. Any command other than a repeated `/rewind` disarms the gate. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/app/app_commands.zig | 119 +++++++++++++++++++++++++-- src/core/app/app_session_runtime.zig | 56 ++++++++++++- 2 files changed, 167 insertions(+), 8 deletions(-) diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig index 3b78251cb..dc185fc1d 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 { @@ -697,12 +704,7 @@ pub fn Handlers(comptime App: type) type { fn commandRewindSession(ctx: *anyopaque, rest: []const u8) !void { const app: *App = @ptrCast(@alignCast(ctx)); - _ = rest; - try app.writeDomainNotice(.{ - .topic = "session", - .tone = .neutral, - .body = "not implemented", - }, true); + try handleRewindCommand(app, rest); } fn commandShowHelp(ctx: *anyopaque) !void { @@ -3453,6 +3455,109 @@ 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 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 1c6b3103f..80be8c90e 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -1176,12 +1176,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"); } } @@ -1205,6 +1206,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 { @@ -2842,6 +2844,58 @@ pub fn Runtime(comptime App: type) type { return .committed; } + /// 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(); + if (requested_turns == 0 or requested_turns > history_len) { + return .{ .out_of_range = history_len }; + } + + const target = RewindTarget{ + .history_len = history_len, + .retained_turns = history_len - requested_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(); From c697ebbbe7c898bf1e0a9f4a4e6ead1f87845c16 Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 03:32:36 -0700 Subject: [PATCH 12/20] Add the /fork slash command `/fork <turn>` branches the live session and moves the shell into the branch. The session is closed before the store call because `forkSessionCopy` takes the source's writer lock, which this process holds while a session is open. That makes every failure past the close a shell with no session, so the source is reopened on each failing path and the notice always says which session the shell ended up on. Background work carries forward rather than stopping, because the source survives a fork and its in-flight commands belong to the branch too. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/app/app_commands.zig | 103 ++++++++++++++++-- src/core/app/app_session_runtime.zig | 153 +++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 6 deletions(-) diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig index dc185fc1d..6e8979d6b 100644 --- a/src/core/app/app_commands.zig +++ b/src/core/app/app_commands.zig @@ -694,12 +694,7 @@ pub fn Handlers(comptime App: type) type { fn commandForkSession(ctx: *anyopaque, rest: []const u8) !void { const app: *App = @ptrCast(@alignCast(ctx)); - _ = rest; - try app.writeDomainNotice(.{ - .topic = "session", - .tone = .neutral, - .body = "not implemented", - }, true); + try handleForkCommand(app, rest); } fn commandRewindSession(ctx: *anyopaque, rest: []const u8) !void { @@ -3483,6 +3478,102 @@ fn writeTurnArgumentUsage(app: anytype, usage: []const u8) !void { ); } +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, + ); + }, + .forked => |fork| try writeForkNotice(app, fork), + } +} + +/// Both ids are named on every path, because after a fork the reader has to +/// know which session they left and which one they can still reach. +fn writeForkNotice( + app: anytype, + fork: app_session_runtime.Runtime(@TypeOf(app.*)).ForkOutcome.Fork, +) !void { + var out: std.Io.Writer.Allocating = .init(app.alloc); + defer out.deinit(); + + if (fork.forked_id) |forked_id| { + try out.writer.print( + "Forked {s} at turn {d} into {s}.", + .{ fork.source_id, fork.retained_turns, forked_id }, + ); + if (fork.problem) |err| { + try out.writer.print( + " The branch could not be opened ({s}); run `fx --resume {s}`.", + .{ @errorName(err), forked_id }, + ); + } + } else { + try out.writer.print( + "Could not fork {s} ({s}).", + .{ fork.source_id, @errorName(fork.problem orelse error.Unknown) }, + ); + } + + switch (fork.landing) { + .branch => try out.writer.print( + " You are now in the branch; {s} is unchanged.", + .{fork.source_id}, + ), + .source => try out.writer.print( + " This shell is still on {s}.", + .{fork.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.", + ), + } + if (fork.unverified_artifacts) { + try out.writer.writeAll( + " Some legacy command artifacts could not be authenticated.", + ); + } + + try app.writeDomainNotice(.{ + .topic = "session", + .tone = if (fork.landing == .branch) .neutral else .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); diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 80be8c90e..473a98ae8 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -2844,6 +2844,159 @@ pub fn Runtime(comptime App: type) type { return .committed; } + /// Where the shell ended up after a `/fork`. A fork has to close the + /// live session before it can run, so "which session am I in now" is + /// part of every answer, not just the failing ones. + pub const ForkLanding = enum { + branch, + source, + fresh_session, + no_session, + }; + + pub const ForkOutcome = union(enum) { + unavailable_during_stream, + unavailable, + out_of_range: usize, + forked: Fork, + + pub const Fork = struct { + source_id: []u8, + /// Null when no branch was created. + forked_id: ?[]u8, + retained_turns: usize, + landing: ForkLanding, + /// Set when the fork or the handoff into the branch failed. + problem: ?anyerror = null, + unverified_artifacts: bool = false, + }; + + pub fn deinit(self: *ForkOutcome, alloc: Allocator) void { + switch (self.*) { + .forked => |*fork| { + alloc.free(fork.source_id); + if (fork.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. Everything after that point has to leave + /// the shell with some session open again. + 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(); + if (at_turn == 0 or at_turn > history_len) { + 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.?; + var copy = store.forkSessionCopy( + app.alloc, + source_id, + at_turn, + .{}, + ) catch |err| return .{ .forked = .{ + .source_id = source_id, + .forked_id = null, + .retained_turns = at_turn, + .landing = reopenSourceAfterFork(app, source_id, log_options), + .problem = err, + } }; + defer copy.deinit(app.alloc); + + const forked_id = try app.alloc.dupe(u8, copy.forked_session_id); + errdefer app.alloc.free(forked_id); + + if (copy.status == .indeterminate) { + return .{ .forked = .{ + .source_id = source_id, + .forked_id = forked_id, + .retained_turns = at_turn, + .landing = reopenSourceAfterFork(app, source_id, log_options), + .problem = error.SessionForkUnconfirmed, + } }; + } + + enterSessionForWrite(app, forked_id, log_options) catch |err| { + return .{ .forked = .{ + .source_id = source_id, + .forked_id = forked_id, + .retained_turns = at_turn, + .landing = reopenSourceAfterFork(app, source_id, log_options), + .problem = err, + } }; + }; + + return .{ .forked = .{ + .source_id = source_id, + .forked_id = forked_id, + .retained_turns = at_turn, + .landing = .branch, + .unverified_artifacts = copy.status == .forked_with_unverified_artifacts, + } }; + } + + 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) { From 47b87979e87a43ee084a7307568996d25a7552d8 Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 03:39:00 -0700 Subject: [PATCH 13/20] Bound fork and rewind arguments through one checked rule Both commands accept 1 through the live history length. Holding that rule in one tested function keeps the two ranges from drifting. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/app/app_session_runtime.zig | 34 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 473a98ae8..1b2862bfb 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -60,6 +60,22 @@ 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 { @@ -2897,9 +2913,8 @@ pub fn Runtime(comptime App: type) type { const active = app.session_persistence.writable orelse return .unavailable; const history_len = app.session.historyLen(); - if (at_turn == 0 or at_turn > history_len) { + 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); @@ -2914,12 +2929,12 @@ pub fn Runtime(comptime App: type) type { var copy = store.forkSessionCopy( app.alloc, source_id, - at_turn, + retained_turns, .{}, ) catch |err| return .{ .forked = .{ .source_id = source_id, .forked_id = null, - .retained_turns = at_turn, + .retained_turns = retained_turns, .landing = reopenSourceAfterFork(app, source_id, log_options), .problem = err, } }; @@ -2932,7 +2947,7 @@ pub fn Runtime(comptime App: type) type { return .{ .forked = .{ .source_id = source_id, .forked_id = forked_id, - .retained_turns = at_turn, + .retained_turns = retained_turns, .landing = reopenSourceAfterFork(app, source_id, log_options), .problem = error.SessionForkUnconfirmed, } }; @@ -2942,7 +2957,7 @@ pub fn Runtime(comptime App: type) type { return .{ .forked = .{ .source_id = source_id, .forked_id = forked_id, - .retained_turns = at_turn, + .retained_turns = retained_turns, .landing = reopenSourceAfterFork(app, source_id, log_options), .problem = err, } }; @@ -2951,7 +2966,7 @@ pub fn Runtime(comptime App: type) type { return .{ .forked = .{ .source_id = source_id, .forked_id = forked_id, - .retained_turns = at_turn, + .retained_turns = retained_turns, .landing = .branch, .unverified_artifacts = copy.status == .forked_with_unverified_artifacts, } }; @@ -3018,13 +3033,12 @@ pub fn Runtime(comptime App: type) type { if (app.stream.active) return .unavailable_during_stream; const history_len = app.session.historyLen(); - if (requested_turns == 0 or requested_turns > history_len) { + 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 - requested_turns, + .retained_turns = history_len - dropped_turns, }; switch (app.session_persistence.rewind_gate.request(target)) { .confirm => return .{ .confirm = target }, From 4b379cfa45c7a1a3979d6e67acbbcbbbe7e1cfa4 Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 03:51:08 -0700 Subject: [PATCH 14/20] Document /fork and /rewind and cover them in the TUI The e2e file drives the real shell through tmux: it builds turns against the fake gateway, proves the rewind needs two identical requests and that an intervening command cancels it, proves the fork names both ids and that the next prompt lands in the branch while the source keeps every turn, and reads every session back off disk afterwards. Classified verification-only, since branching and undo are deliberate rare operations that must stay correct without being made hot. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- .gitignore | 4 + README.md | 11 ++ scripts/pgso/corpus.json | 1 + tests/e2e/ci-shard-weights.json | 1 + tests/e2e/tui-session-fork.test.ts | 279 +++++++++++++++++++++++++++++ 5 files changed, 296 insertions(+) create mode 100644 tests/e2e/tui-session-fork.test.ts 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 <id> --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/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/tui-session-fork.test.ts b/tests/e2e/tui-session-fork.test.ts new file mode 100644 index 000000000..c85264db1 --- /dev/null +++ b/tests/e2e/tui-session-fork.test.ts @@ -0,0 +1,279 @@ +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, + 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( + "/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, + ); +}); From 5be36db4904b61a426b23d06947ebc8930489c30 Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 04:28:27 -0700 Subject: [PATCH 15/20] Split the fork outcome into a branch and a failure A successful fork always has both ids and always lands in the branch; a failed one always has a reason and lands somewhere else. Modelling them as one struct left `forked_id` and `problem` coupled by convention. One `forkFailure` helper now owns the rule that every failure past the session close reopens a session before answering, which also closes the path where an allocation failure returned without reopening one. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/app/app_commands.zig | 75 ++++++++++++-------- src/core/app/app_session_runtime.zig | 102 +++++++++++++++------------ tests/e2e/tui-session-fork.test.ts | 47 ++++++++++++ 3 files changed, 149 insertions(+), 75 deletions(-) diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig index 6e8979d6b..6d1e35c14 100644 --- a/src/core/app/app_commands.zig +++ b/src/core/app/app_commands.zig @@ -3513,45 +3513,64 @@ fn handleForkCommand(app: anytype, rest: []const u8) !void { true, ); }, - .forked => |fork| try writeForkNotice(app, fork), + .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), } } -/// Both ids are named on every path, because after a fork the reader has to -/// know which session they left and which one they can still reach. -fn writeForkNotice( +/// 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, - fork: app_session_runtime.Runtime(@TypeOf(app.*)).ForkOutcome.Fork, + failure: app_session_runtime.Runtime(@TypeOf(app.*)).ForkOutcome.Failure, ) !void { var out: std.Io.Writer.Allocating = .init(app.alloc); defer out.deinit(); - if (fork.forked_id) |forked_id| { + if (failure.forked_id) |forked_id| { try out.writer.print( - "Forked {s} at turn {d} into {s}.", - .{ fork.source_id, fork.retained_turns, forked_id }, + "Forked {s} into {s} but could not open it ({s}); run `fx --resume {s}`.", + .{ + failure.source_id, + forked_id, + @errorName(failure.problem), + forked_id, + }, ); - if (fork.problem) |err| { - try out.writer.print( - " The branch could not be opened ({s}); run `fx --resume {s}`.", - .{ @errorName(err), forked_id }, - ); - } } else { try out.writer.print( "Could not fork {s} ({s}).", - .{ fork.source_id, @errorName(fork.problem orelse error.Unknown) }, + .{ failure.source_id, @errorName(failure.problem) }, ); } - switch (fork.landing) { - .branch => try out.writer.print( - " You are now in the branch; {s} is unchanged.", - .{fork.source_id}, - ), + switch (failure.landing) { .source => try out.writer.print( " This shell is still on {s}.", - .{fork.source_id}, + .{failure.source_id}, ), .fresh_session => try out.writer.writeAll( " This shell is on a new empty session.", @@ -3560,17 +3579,11 @@ fn writeForkNotice( " This shell has no session; run /resume to open one.", ), } - if (fork.unverified_artifacts) { - try out.writer.writeAll( - " Some legacy command artifacts could not be authenticated.", - ); - } - try app.writeDomainNotice(.{ - .topic = "session", - .tone = if (fork.landing == .branch) .neutral else .warning, - .body = out.written(), - }, true); + try app.writeDomainNotice( + .{ .topic = "session", .tone = .warning, .body = out.written() }, + true, + ); app.shell.render_requests.request(.footer); } diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 1b2862bfb..296d745b0 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -2860,11 +2860,10 @@ pub fn Runtime(comptime App: type) type { return .committed; } - /// Where the shell ended up after a `/fork`. A fork has to close the - /// live session before it can run, so "which session am I in now" is - /// part of every answer, not just the failing ones. + /// 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 { - branch, source, fresh_session, no_session, @@ -2874,24 +2873,33 @@ pub fn Runtime(comptime App: type) type { unavailable_during_stream, unavailable, out_of_range: usize, - forked: Fork, + branched: Branch, + failed: Failure, - pub const Fork = struct { + pub const Branch = struct { source_id: []u8, - /// Null when no branch was created. - forked_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, - /// Set when the fork or the handoff into the branch failed. - problem: ?anyerror = null, - unverified_artifacts: bool = false, }; pub fn deinit(self: *ForkOutcome, alloc: Allocator) void { switch (self.*) { - .forked => |*fork| { - alloc.free(fork.source_id); - if (fork.forked_id) |id| alloc.free(id); + .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 => {}, } @@ -2902,8 +2910,8 @@ pub fn Runtime(comptime App: type) type { /// 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. Everything after that point has to leave - /// the shell with some session open again. + /// 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; @@ -2925,53 +2933,59 @@ pub fn Runtime(comptime App: type) type { }; try prepareLiveSessionTransition(app, .carry_forward, log_options); - const store = app.session_persistence.store.?; + 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 .{ .forked = .{ - .source_id = source_id, - .forked_id = null, - .retained_turns = retained_turns, - .landing = reopenSourceAfterFork(app, source_id, log_options), - .problem = err, - } }; + ) catch |err| return forkFailure(app, source_id, null, log_options, err); defer copy.deinit(app.alloc); - const forked_id = try app.alloc.dupe(u8, copy.forked_session_id); - errdefer app.alloc.free(forked_id); + 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 .{ .forked = .{ - .source_id = source_id, - .forked_id = forked_id, - .retained_turns = retained_turns, - .landing = reopenSourceAfterFork(app, source_id, log_options), - .problem = error.SessionForkUnconfirmed, - } }; + return forkFailure( + app, + source_id, + forked_id, + log_options, + error.SessionForkUnconfirmed, + ); } - enterSessionForWrite(app, forked_id, log_options) catch |err| { - return .{ .forked = .{ - .source_id = source_id, - .forked_id = forked_id, - .retained_turns = retained_turns, - .landing = reopenSourceAfterFork(app, source_id, log_options), - .problem = err, - } }; - }; + enterSessionForWrite(app, forked_id, log_options) catch |err| + return forkFailure(app, source_id, forked_id, log_options, err); - return .{ .forked = .{ + return .{ .branched = .{ .source_id = source_id, .forked_id = forked_id, .retained_turns = retained_turns, - .landing = .branch, .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, diff --git a/tests/e2e/tui-session-fork.test.ts b/tests/e2e/tui-session-fork.test.ts index c85264db1..3965d065d 100644 --- a/tests/e2e/tui-session-fork.test.ts +++ b/tests/e2e/tui-session-fork.test.ts @@ -6,6 +6,7 @@ import { FX_BIN, runFx } from "../evals/eval-helpers"; import { TmuxSession, fakeGatewayFinalText, + heldFakeGatewayFinalText, startFakeGateway, } from "./tmux-helpers"; @@ -253,6 +254,52 @@ describe("interactive session fork and rewind", () => { 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 () => { From caa2608468f85fbb06f1c59066a1d2ee25d33fdc Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 04:28:53 -0700 Subject: [PATCH 16/20] Keep the rewind gate internal to the session runtime Only `RewindTarget` crosses into the command layer. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/app/app_session_runtime.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 296d745b0..9ddffaddf 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -87,7 +87,7 @@ pub const RewindTarget = struct { } }; -pub const RewindRequest = enum { +const RewindRequest = enum { confirm, execute, }; @@ -96,7 +96,7 @@ pub const RewindRequest = enum { /// 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. -pub const RewindGate = union(enum) { +const RewindGate = union(enum) { idle, armed: RewindTarget, From 36725c44cb497b9553ed66132fd953f395cbfa3d Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 19:29:34 -0700 Subject: [PATCH 17/20] Fork the whole session from bare /fork A bare /fork branches at the current point, the way codex fork does, and names the original id so the user can get back to it with /resume. The numeric /fork <turn> form is unchanged for branching at an earlier turn. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/builtins/commands.zig | 2 +- src/core/app/app_commands.zig | 47 +++++++++++++++++++++--------- tests/e2e/tui-session-fork.test.ts | 2 +- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/builtins/commands.zig b/src/builtins/commands.zig index 9e025912b..5c4899857 100644 --- a/src/builtins/commands.zig +++ b/src/builtins/commands.zig @@ -442,8 +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 <title>", .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 = .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 = .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 }, diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig index 6d1e35c14..211dd379a 100644 --- a/src/core/app/app_commands.zig +++ b/src/core/app/app_commands.zig @@ -3482,10 +3482,23 @@ 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>"); + 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); @@ -3516,16 +3529,24 @@ fn handleForkCommand(app: anytype, rest: []const u8) !void { .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 (bare) { + try out.writer.print( + "Forked {s} into {s}. You are now in the branch; " ++ + "resume the original with /resume or fx --resume {s}.", + .{ branch.source_id, branch.forked_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.", diff --git a/tests/e2e/tui-session-fork.test.ts b/tests/e2e/tui-session-fork.test.ts index 3965d065d..ef3df090d 100644 --- a/tests/e2e/tui-session-fork.test.ts +++ b/tests/e2e/tui-session-fork.test.ts @@ -121,7 +121,7 @@ async function quitShell(stderrPath: string) { describe("interactive session fork and rewind", () => { test( - "a bare /fork or /rewind names its argument instead of acting", + "bare /fork branches the full session and leaves the source unchanged", async () => { const { home, workspace, stderrPath } = await startShell( ["REPLY_ONE"], From b17a6022cb828a4c4d2d688411691e8566efcd95 Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 19:29:45 -0700 Subject: [PATCH 18/20] Add an interactive turn picker for bare /rewind Bare /rewind opens an inline menu listing every prompt with the newest at the bottom and a (current) row where the cursor starts. Selecting a prompt restores the conversation to the point before it and puts that prompt back in the composer for editing. The footer previews the effect before Enter, so the picker is its own confirmation and does not go through the two-step gate that the numeric /rewind <count> form keeps. The picker is a compact footer menu like /statusline, so it never takes the alternate screen. Its window is sized by one constant shared between the reducer and the presentation, and clipped rows show how many prompts sit above and below. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- README.md | 2 +- src/builtins/commands.zig | 2 +- src/core/app/app_commands.zig | 21 ++- src/core/app/app_input_runtime.zig | 26 ++- src/core/app/app_render_runtime.zig | 11 ++ src/core/app/app_session_runtime.zig | 160 +++++++++++++++++- .../compact_command_menu_presentation.zig | 87 +++++++++- src/ui/footer/input_presentation.zig | 5 + src/ui/footer/render_input.zig | 12 ++ tests/e2e/tui-session-fork.test.ts | 85 +++++++++- 10 files changed, 394 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c9bd41f89..abc0a5cba 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ The interactive shell has the same two operations for the session it is already /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. +`/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. diff --git a/src/builtins/commands.zig b/src/builtins/commands.zig index 5c4899857..c4816fbf9 100644 --- a/src/builtins/commands.zig +++ b/src/builtins/commands.zig @@ -442,8 +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 <title>", .completion_description = "rename the current session", .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 = .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 }, diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig index 211dd379a..04c2fdcf3 100644 --- a/src/core/app/app_commands.zig +++ b/src/core/app/app_commands.zig @@ -3612,7 +3612,26 @@ fn handleRewindCommand(app: anytype, rest: []const u8) !void { const App = @TypeOf(app.*); const SessionRuntime = app_session_runtime.Runtime(App); - const requested = parsedTurnCount(rest) orelse { + 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; }; 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 9ddffaddf..da10873f9 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -162,6 +162,93 @@ test "rewind target reports the turns it drops" { 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, @@ -1193,12 +1280,13 @@ pub const Persistence = struct { 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 != 20) { + if (std.meta.fields(Persistence).len != 21) { @compileError("update Persistence.initInto for the changed field set"); } } @@ -1223,6 +1311,7 @@ pub const Persistence = struct { 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 { @@ -2513,9 +2602,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, @@ -3059,7 +3157,14 @@ pub fn Runtime(comptime App: type) type { .execute => {}, } - app.session.truncateHistory(app.alloc, target.retained_turns); + 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()); @@ -3070,7 +3175,56 @@ pub fn Runtime(comptime App: type) type { // dropped, so its checkpoint cannot outlive the turns. try commitCurrentStateReplacement(app, loaded, .rewind, .{}, true); } - return .{ .rewound = target }; + } + + 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 { 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/tests/e2e/tui-session-fork.test.ts b/tests/e2e/tui-session-fork.test.ts index ef3df090d..0e5ea22c2 100644 --- a/tests/e2e/tui-session-fork.test.ts +++ b/tests/e2e/tui-session-fork.test.ts @@ -129,16 +129,28 @@ describe("interactive session fork and rewind", () => { ); 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("Use: /fork <turn>", STEP_TIMEOUT); - await session!.sendText("/rewind"); - await session!.waitForText("Use: /rewind <count>", STEP_TIMEOUT); - expect(await flatPane()).toContain("Run`fxsession"); + 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 quitShell(stderrPath); const saved = await savedSessions(home, workspace); - expect(saved).toHaveLength(1); - expect(saved[0]!.prompts).toEqual(["first prompt"]); + expect(saved).toHaveLength(2); + for (const entry of saved) expect(entry.prompts).toEqual(["first prompt"]); }, TIMEOUT, ); @@ -183,6 +195,67 @@ describe("interactive session fork and rewind", () => { 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 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 () => { From f7f2f74e2cb530a7595016160464a626846f8a45 Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 20:14:27 -0700 Subject: [PATCH 19/20] Redraw the transcript after a rewind Inline rendering only appends, so dropping turns from view needs the same retained-history redraw resume already does: clear the shell's transcript, rebuild it from the truncated history, and reset the terminal. Both the picker and the numeric form go through it, and the picker's composer prefill lands after the reset so it survives. The rebuild is factored out of hydrateResumedSession so resume and rewind share one replay path; resume keeps its notice and recovery checkpoint around the shared core. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- src/core/app/app_session_runtime.zig | 57 ++++++++++++++++++++++++---- tests/e2e/tui-session-fork.test.ts | 8 ++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index da10873f9..0a5d7cbc5 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -1749,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); } @@ -2150,6 +2154,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(); @@ -2158,9 +2188,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(); @@ -2170,7 +2204,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), @@ -2178,9 +2212,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); + } } } @@ -3175,6 +3213,11 @@ pub fn Runtime(comptime App: type) type { // 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 { diff --git a/tests/e2e/tui-session-fork.test.ts b/tests/e2e/tui-session-fork.test.ts index 0e5ea22c2..7eba105b5 100644 --- a/tests/e2e/tui-session-fork.test.ts +++ b/tests/e2e/tui-session-fork.test.ts @@ -186,6 +186,10 @@ describe("interactive session fork and rewind", () => { 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); @@ -222,6 +226,10 @@ describe("interactive session fork and rewind", () => { (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); From dba14a8618af70c4053b3109fdda8ed5094ff31c Mon Sep 17 00:00:00 2001 From: Aarya2004 <aaryaprakash2022@gmail.com> Date: Tue, 1 Sep 2026 20:50:32 -0700 Subject: [PATCH 20/20] Let /resume take a session id or last The bare-fork message tells the user to resume the original session, and the natural thing to type is the id it just printed. /resume now accepts `last` or an exact id and goes through the same live transition the session picker uses; bare /resume still opens the picker. `last` is resolved once, through the read-only workspace summary the CLI uses, so the id compared against the active session is the id that gets opened. The admission view resolves "latest" from a pointer that can disagree with the writable path, which locked the shell's own session. Claude-Session: https://claude.ai/code/session_01Hjm7J6N3SL5Y62TJ3bPxwD --- README.md | 2 + src/builtins/commands.zig | 2 +- src/core/app/app_commands.zig | 52 ++++++++++++++++++++-- src/core/app/app_session_runtime.zig | 28 +++++++++++- src/core/slash_commands/command_router.zig | 28 ++++++++---- src/core/slash_commands/command_specs.zig | 6 +-- tests/e2e/tui-session-fork.test.ts | 23 +++++++++- tests/e2e/tui-slash-menu.test.ts | 10 +++-- 8 files changed, 127 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index abc0a5cba..52f411cfc 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ fx session resume last fx session resume --id <id> ``` +Inside the interactive shell, use `/resume` to open the session picker, `/resume last` for the latest workspace session, or `/resume <id>` for an exact session. + `fx session <id>` prints the saved conversation with a `[turn N]` label above every turn. Those labels are the boundaries the branch and undo commands take: ```bash diff --git a/src/builtins/commands.zig b/src/builtins/commands.zig index c4816fbf9..24c25f40b 100644 --- a/src/builtins/commands.zig +++ b/src/builtins/commands.zig @@ -439,7 +439,7 @@ 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|<id>]", .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 <title>", .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 }, diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig index 04c2fdcf3..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"); @@ -653,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(.{ @@ -663,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 { @@ -3532,8 +3576,8 @@ fn handleForkCommand(app: anytype, rest: []const u8) !void { if (bare) { try out.writer.print( "Forked {s} into {s}. You are now in the branch; " ++ - "resume the original with /resume or fx --resume {s}.", - .{ branch.source_id, branch.forked_id, branch.source_id }, + "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( diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 0a5d7cbc5..90c11652e 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -2049,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; diff --git a/src/core/slash_commands/command_router.zig b/src/core/slash_commands/command_router.zig index 0d4511776..81dab4049 100644 --- a/src/core/slash_commands/command_router.zig +++ b/src/core/slash_commands/command_router.zig @@ -9,7 +9,7 @@ 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, @@ -55,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, @@ -105,7 +105,7 @@ 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 }, @@ -172,7 +172,7 @@ pub fn dispatch( .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), @@ -264,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" { @@ -463,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 { @@ -513,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, @@ -573,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 879095dc7..05fa530c8 100644 --- a/src/core/slash_commands/command_specs.zig +++ b/src/core/slash_commands/command_specs.zig @@ -1933,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/tests/e2e/tui-session-fork.test.ts b/tests/e2e/tui-session-fork.test.ts index 7eba105b5..c98c655d6 100644 --- a/tests/e2e/tui-session-fork.test.ts +++ b/tests/e2e/tui-session-fork.test.ts @@ -124,7 +124,7 @@ describe("interactive session fork and rewind", () => { "bare /fork branches the full session and leaves the source unchanged", async () => { const { home, workspace, stderrPath } = await startShell( - ["REPLY_ONE"], + ["REPLY_ONE", "SOURCE_REPLY"], "fx-tui-fork-usage-", ); await ask("first prompt", "REPLY_ONE"); @@ -147,10 +147,29 @@ describe("interactive session fork and rewind", () => { 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); - for (const entry of saved) expect(entry.prompts).toEqual(["first prompt"]); + 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, ); diff --git a/tests/e2e/tui-slash-menu.test.ts b/tests/e2e/tui-slash-menu.test.ts index a171b697a..d3a6a6e64 100644 --- a/tests/e2e/tui-slash-menu.test.ts +++ b/tests/e2e/tui-slash-menu.test.ts @@ -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");