diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 50861f2a5..6445443f9 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -255,7 +255,7 @@ Use `/session` in interactive mode to see the current session ID before reusing ### Branching -**`/tree`** - Navigate the session tree in-place. Select any previous point, continue from there, and switch between branches. All history preserved in a single file. +**`/tree`** - Navigate the session tree in-place. Select any previous point, continue from there, and switch between branches. All history preserved in a single file. Selecting a point while the model is responding cancels that response. Navigation cannot proceed while compaction or another tree navigation is still running; wait for it to finish and retry.

Tree View

diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 4eed3ca71..1fb0564ba 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -1203,7 +1203,7 @@ Options: ### ctx.navigateTree(targetId, options?) -Navigate to a different point in the session tree: +Navigate to a different point in the session tree. Rejects while an agent response, manual or automatic compaction, or another tree navigation is active, even with `summarize: false`. These conflicts leave the active branch unchanged and reject the promise rather than returning `{ cancelled: true }`. Wait for the active operation to finish (for example, with `await ctx.waitForIdle()` in a command handler) and retry: ```typescript const result = await ctx.navigateTree("entry-id-456", { diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 095bf2493..63fe22d0e 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -111,6 +111,8 @@ interface AgentSession { } ``` +`session.navigateTree()` rejects while an agent response, manual or automatic compaction, or another tree navigation is active, even with `summarize: false`. It does not queue navigation or return `{ cancelled: true }` for these conflicts. Wait for the active operation to finish (for example, with `await session.waitForIdle()`) and retry. Rejection leaves the active branch unchanged. + Session replacement APIs such as new-session, resume, fork, and import live on `AgentSessionRuntime`, not on `AgentSession`. ### createAgentSessionRuntime() and AgentSessionRuntime diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 84456fc02..b84f96f14 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -5270,6 +5270,14 @@ export class InteractiveMode { await this.session.abort(); } + // Recheck after the dialogs and streaming abort, before replacing another operation's UI. + if (this.session.isCompacting) { + this.showError( + "Wait for the current compaction or tree navigation to finish before navigating the session tree.", + ); + return; + } + // Set up escape handler and status indicator if summarizing let showingSummaryIndicator = false; const originalOnEscape = this.defaultEditor.onEscape; diff --git a/packages/coding-agent/test/interactive-mode-tree-navigation.test.ts b/packages/coding-agent/test/interactive-mode-tree-navigation.test.ts new file mode 100644 index 000000000..9b87993dd --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-tree-navigation.test.ts @@ -0,0 +1,144 @@ +import { Container } from "@earendil-works/pi-tui"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionManager } from "../src/core/session-manager.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; +import type { StatusIndicator } from "../src/modes/interactive/components/status-indicator.ts"; +import type { TreeSelectorComponent } from "../src/modes/interactive/components/tree-selector.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { assistantMsg, userMsg } from "./utilities.ts"; + +const busyMessage = "Wait for the current compaction or tree navigation to finish before navigating the session tree."; + +function createTreeUI() { + const sessionManager = SessionManager.inMemory(); + const targetId = sessionManager.appendMessage(userMsg("first")); + sessionManager.appendMessage(assistantMsg("reply")); + let selector: TreeSelectorComponent | undefined; + const onEscape = vi.fn(); + const ui = { + sessionManager, + settingsManager: SettingsManager.inMemory(), + session: { + isStreaming: false, + isCompacting: false, + abort: vi.fn(async () => { + ui.session.isStreaming = false; + }), + abortBranchSummary: vi.fn(), + navigateTree: vi.fn(async () => { + if (ui.session.isCompacting) throw new Error(busyMessage); + return { cancelled: false }; + }), + }, + defaultEditor: { onEscape }, + editor: { getText: () => "", setText: vi.fn() }, + chatContainer: new Container(), + isInitialized: true, + footer: { invalidate: vi.fn() }, + ui: { terminal: { rows: 24, setProgress: vi.fn() }, requestRender: vi.fn() }, + showSelector: ( + create: (done: () => void) => { component: TreeSelectorComponent; focus: TreeSelectorComponent }, + ) => { + selector = create(vi.fn()).component; + }, + showExtensionSelector: vi.fn(async () => "No summary"), + // Dispose newly created indicators so a failing regression cannot leak spinner timers. + showStatusIndicator: vi.fn((indicator: StatusIndicator) => indicator.dispose()), + clearStatusIndicator: vi.fn(), + restoreQueuedMessagesToEditor: vi.fn(), + renderInitialMessages: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + flushCompactionQueue: vi.fn(async () => {}), + }; + const showTreeSelector = Reflect.get(InteractiveMode.prototype, "showTreeSelector") as (this: typeof ui) => void; + showTreeSelector.call(ui); + + return { + ui, + onEscape, + targetId, + async select() { + expect(selector).toBeDefined(); + await selector!.getTreeList().onSelect!(targetId); + }, + }; +} + +describe("InteractiveMode tree navigation availability", () => { + beforeEach(() => initTheme("dark")); + + // Regression for #9178 / PR #9179: rejection must not replace the active operation's UI. + it.each(["Summarize", "No summary"])("preserves operation UI when choosing %s while busy", async (choice) => { + const { ui, onEscape, select } = createTreeUI(); + const originalLeafId = ui.sessionManager.getLeafId(); + ui.showExtensionSelector.mockImplementation(async () => { + // Compaction or another navigation can start while the dialog is open. + ui.session.isCompacting = true; + return choice; + }); + + await select(); + + expect(ui.showError).toHaveBeenCalledWith(busyMessage); + expect(ui.showStatusIndicator).not.toHaveBeenCalled(); + expect(ui.clearStatusIndicator).not.toHaveBeenCalled(); + expect(ui.defaultEditor.onEscape).toBe(onEscape); + expect(ui.session.navigateTree).not.toHaveBeenCalled(); + expect(ui.session.abort).not.toHaveBeenCalled(); + expect(ui.sessionManager.getLeafId()).toBe(originalLeafId); + }); + + it("allows navigation when compaction finishes while the dialog is open", async () => { + const { ui, targetId, select } = createTreeUI(); + ui.session.isCompacting = true; + ui.showExtensionSelector.mockImplementation(async () => { + ui.session.isCompacting = false; + return "No summary"; + }); + + await select(); + + expect(ui.session.navigateTree).toHaveBeenCalledWith(targetId, { + summarize: false, + customInstructions: undefined, + }); + expect(ui.showError).not.toHaveBeenCalled(); + }); + + it("still aborts an active response before navigating", async () => { + const { ui, select } = createTreeUI(); + ui.session.isStreaming = true; + ui.session.navigateTree.mockImplementation(async () => { + expect(ui.session.isStreaming).toBe(false); + expect(ui.restoreQueuedMessagesToEditor).toHaveBeenCalledOnce(); + return { cancelled: false }; + }); + + await select(); + + expect(ui.session.abort).toHaveBeenCalledOnce(); + expect(ui.session.navigateTree).toHaveBeenCalledOnce(); + expect(ui.showError).not.toHaveBeenCalled(); + }); + + it("rechecks availability after the response abort settles", async () => { + const { ui, onEscape, select } = createTreeUI(); + ui.session.isStreaming = true; + ui.showExtensionSelector.mockResolvedValue("Summarize"); + ui.session.abort.mockImplementation(async () => { + ui.session.isStreaming = false; + ui.session.isCompacting = true; + }); + + await select(); + + expect(ui.session.abort).toHaveBeenCalledOnce(); + expect(ui.showError).toHaveBeenCalledWith(busyMessage); + expect(ui.showStatusIndicator).not.toHaveBeenCalled(); + expect(ui.clearStatusIndicator).not.toHaveBeenCalled(); + expect(ui.defaultEditor.onEscape).toBe(onEscape); + expect(ui.session.navigateTree).not.toHaveBeenCalled(); + }); +});