From b9ab6a0067cb6f72a47571addd77be4675661c1d Mon Sep 17 00:00:00 2001 From: Aarya2004 Date: Tue, 1 Sep 2026 02:31:16 -0700 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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,