diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4ea74419f..1f4874ea8 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1945,7 +1945,7 @@ export class InteractiveMode { if (result.editorText && !this.editor.getText().trim()) { this.editor.setText(result.editorText); } - this.showStatus("Navigated to selected point"); + this.showTreeNavigationStatus(); void this.flushCompactionQueue({ willRetry: false }); return { cancelled: false }; }, @@ -1976,6 +1976,14 @@ export class InteractiveMode { this.showStartupNoticesIfNeeded(); } + private showTreeNavigationStatus(): void { + this.showStatus( + this.session.pendingPlanContinuation + ? "Navigated to selected point. Accepted plan implementation is waiting; use /plan to retry." + : "Navigated to selected point", + ); + } + private applyFullscreenScrollbarSetting(): void { this.transcriptScrollView?.setScrollbar(this.settingsManager.getFullscreenScrollbar()); } @@ -5428,7 +5436,7 @@ export class InteractiveMode { if (result.editorText && !this.editor.getText().trim()) { this.editor.setText(result.editorText); } - this.showStatus("Navigated to selected point"); + this.showTreeNavigationStatus(); void this.flushCompactionQueue({ willRetry: false }); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); diff --git a/packages/coding-agent/test/interactive-mode-plan-lifecycle.test.ts b/packages/coding-agent/test/interactive-mode-plan-lifecycle.test.ts new file mode 100644 index 000000000..56f6cedba --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-plan-lifecycle.test.ts @@ -0,0 +1,280 @@ +import { beforeAll, describe, expect, test, vi } from "vitest"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { + type AgentSettledEventContext, + handleAgentSettled, + PLAN, + type RebindContext, + type RunFlowContext, + rebindCurrentSession, + runInteractive, + showTreeNavigationStatus, + type TreeNavigationStatusContext, +} from "./interactive-mode-plan-test-utils.ts"; + +beforeAll(() => initTheme("dark")); + +describe("InteractiveMode plan lifecycle", () => { + test("opens only after startup prompts and after each serialized main-loop prompt", async () => { + vi.stubEnv("DIFFEX_OFFLINE", "1"); + vi.stubEnv("DIFFEX_SKIP_VERSION_CHECK", "1"); + const stop = new Error("stop interactive loop"); + const order: string[] = []; + const context: RunFlowContext = { + init: vi.fn().mockResolvedValue(undefined), + version: "0.0.0", + isInitialized: true, + options: { + initialMessage: "initial file prompt", + initialMessages: ["first positional prompt", "second positional prompt"], + }, + session: { modelRuntime: { getError: () => undefined } }, + checkForPackageUpdates: vi.fn().mockResolvedValue([]), + checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn().mockResolvedValue(undefined), + showNewVersionNotification: vi.fn(), + showWarning: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + updateTerminalTitle: vi.fn(), + openPendingPlanDecision: vi.fn(() => order.push("open")), + promptUser: vi.fn(async (message: string) => { + order.push(`prompt:${message}`); + }), + getUserInput: vi.fn().mockResolvedValueOnce("main-loop prompt").mockRejectedValueOnce(stop), + editor: { getText: () => "", setText: vi.fn() }, + ui: { requestRender: vi.fn() }, + }; + + try { + await expect(runInteractive.call(context)).rejects.toBe(stop); + expect(order).toEqual([ + "prompt:initial file prompt", + "prompt:first positional prompt", + "prompt:second positional prompt", + "open", + "prompt:main-loop prompt", + "open", + ]); + } finally { + vi.unstubAllEnvs(); + } + }); + + test("stops startup prompts when one submits a plan before opening the decision", async () => { + vi.stubEnv("DIFFEX_OFFLINE", "1"); + vi.stubEnv("DIFFEX_SKIP_VERSION_CHECK", "1"); + const stop = new Error("stop interactive loop"); + const order: string[] = []; + const session: RunFlowContext["session"] = { modelRuntime: { getError: () => undefined } }; + const context: RunFlowContext = { + init: vi.fn().mockResolvedValue(undefined), + version: "0.0.0", + isInitialized: true, + options: { + initialMessage: "initial file prompt", + initialMessages: [ + "first positional prompt", + "must wait for plan resolution", + "also wait for plan resolution", + ], + }, + session, + checkForPackageUpdates: vi.fn().mockResolvedValue([]), + checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn().mockResolvedValue(undefined), + showNewVersionNotification: vi.fn(), + showWarning: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + updateTerminalTitle: vi.fn(), + openPendingPlanDecision: vi.fn(() => order.push("open")), + promptUser: vi.fn(async (message: string) => { + order.push(`prompt:${message}`); + if (message === "first positional prompt") session.pendingPlan = PLAN; + }), + getUserInput: vi.fn().mockRejectedValue(stop), + editor: { getText: () => "", setText: vi.fn() }, + ui: { requestRender: vi.fn() }, + }; + + try { + await expect(runInteractive.call(context)).rejects.toBe(stop); + expect(order).toEqual(["prompt:initial file prompt", "prompt:first positional prompt", "open"]); + expect(context.editor.setText).toHaveBeenCalledWith( + "must wait for plan resolution\n\nalso wait for plan resolution", + ); + expect(context.showStatus).toHaveBeenCalledWith( + "2 startup prompts are waiting in the editor for plan resolution.", + ); + } finally { + vi.unstubAllEnvs(); + } + }); + + test("preserves every startup prompt when a restored session already has a pending plan", async () => { + vi.stubEnv("DIFFEX_OFFLINE", "1"); + vi.stubEnv("DIFFEX_SKIP_VERSION_CHECK", "1"); + const stop = new Error("stop interactive loop"); + const context: RunFlowContext = { + init: vi.fn().mockResolvedValue(undefined), + version: "0.0.0", + isInitialized: true, + options: { + initialMessage: "initial file prompt", + initialImages: [{}], + initialMessages: ["first positional prompt", "second positional prompt"], + }, + session: { modelRuntime: { getError: () => undefined }, pendingPlan: PLAN }, + checkForPackageUpdates: vi.fn().mockResolvedValue([]), + checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn().mockResolvedValue(undefined), + showNewVersionNotification: vi.fn(), + showWarning: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + updateTerminalTitle: vi.fn(), + openPendingPlanDecision: vi.fn(), + promptUser: vi.fn(), + getUserInput: vi.fn().mockRejectedValue(stop), + editor: { getText: () => "", setText: vi.fn() }, + ui: { requestRender: vi.fn() }, + }; + + try { + await expect(runInteractive.call(context)).rejects.toBe(stop); + expect(context.promptUser).not.toHaveBeenCalled(); + expect(context.editor.setText).toHaveBeenCalledWith( + "initial file prompt\n\nfirst positional prompt\n\nsecond positional prompt", + ); + expect(context.showStatus).toHaveBeenCalledWith( + "3 startup prompts are waiting in the editor for plan resolution. 1 attached startup image must be reattached after plan resolution.", + ); + expect(context.openPendingPlanDecision).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllEnvs(); + } + }); + + test("preserves startup input and announces a restored accepted continuation", async () => { + vi.stubEnv("DIFFEX_OFFLINE", "1"); + vi.stubEnv("DIFFEX_SKIP_VERSION_CHECK", "1"); + const stop = new Error("stop interactive loop"); + const continuation = Object.freeze({ message: "Implement the restored accepted plan." }); + const context: RunFlowContext = { + init: vi.fn().mockResolvedValue(undefined), + version: "0.0.0", + isInitialized: true, + options: { + initialMessage: "do not send before the accepted plan", + initialImages: [{}], + initialMessages: ["also wait"], + }, + session: { + modelRuntime: { getError: () => undefined }, + pendingPlanContinuation: continuation, + }, + checkForPackageUpdates: vi.fn().mockResolvedValue([]), + checkTmuxKeyboardSetup: vi.fn().mockResolvedValue(undefined), + maybeWarnAboutAnthropicSubscriptionAuth: vi.fn().mockResolvedValue(undefined), + showNewVersionNotification: vi.fn(), + showWarning: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + updateTerminalTitle: vi.fn(), + openPendingPlanDecision: vi.fn(), + promptUser: vi.fn(), + getUserInput: vi.fn().mockRejectedValue(stop), + editor: { getText: () => "", setText: vi.fn() }, + ui: { requestRender: vi.fn() }, + }; + + try { + await expect(runInteractive.call(context)).rejects.toBe(stop); + expect(context.promptUser).not.toHaveBeenCalled(); + expect(context.editor.setText).toHaveBeenCalledWith("do not send before the accepted plan\n\nalso wait"); + expect(context.showStatus).toHaveBeenCalledWith( + "Accepted plan implementation is waiting. Use /plan to retry. 2 startup prompts are waiting in the editor. 1 attached startup image must be reattached before sending.", + ); + } finally { + vi.unstubAllEnvs(); + } + }); + + test("announces an accepted continuation after session rebind without executing it", async () => { + const continuation = Object.freeze({ message: "Implement the rebound accepted plan." }); + const context: RebindContext = { + session: { pendingPlanContinuation: continuation }, + applyRuntimeSettings: vi.fn(), + renderCurrentSessionState: vi.fn(), + subscribeToAgent: vi.fn(), + bindCurrentSessionExtensions: vi.fn().mockResolvedValue(undefined), + updateAvailableProviderCount: vi.fn().mockResolvedValue(undefined), + updateEditorBorderColor: vi.fn(), + updateTerminalTitle: vi.fn(), + showStatus: vi.fn(), + openPendingPlanDecision: vi.fn(), + }; + + await rebindCurrentSession.call(context, { renderBeforeBind: true }); + + expect(context.showStatus).toHaveBeenCalledWith("Accepted plan implementation is waiting. Use /plan to retry."); + expect(context.openPendingPlanDecision).toHaveBeenCalledOnce(); + }); + + test.each([ + { + continuation: undefined, + message: "Navigated to selected point", + }, + { + continuation: Object.freeze({ message: "Implement the restored accepted plan." }), + message: "Navigated to selected point. Accepted plan implementation is waiting; use /plan to retry.", + }, + ])("reports accepted-plan continuation state after either tree navigation path", ({ continuation, message }) => { + const context: TreeNavigationStatusContext = { + session: { pendingPlanContinuation: continuation }, + showStatus: vi.fn(), + }; + + showTreeNavigationStatus.call(context); + + expect(context.showStatus).toHaveBeenCalledWith(message); + }); + + test("does not replace the harness-switch notice with a retry notice during continuation rebind", async () => { + const continuation = Object.freeze({ message: "Implement the rebound accepted plan." }); + const context: RebindContext = { + session: { pendingPlanContinuation: continuation }, + activeEditorUi: { preserveOnSessionRebind: true }, + applyRuntimeSettings: vi.fn(), + renderCurrentSessionState: vi.fn(), + subscribeToAgent: vi.fn(), + bindCurrentSessionExtensions: vi.fn().mockResolvedValue(undefined), + updateAvailableProviderCount: vi.fn().mockResolvedValue(undefined), + updateEditorBorderColor: vi.fn(), + updateTerminalTitle: vi.fn(), + showStatus: vi.fn(), + openPendingPlanDecision: vi.fn(), + }; + + await rebindCurrentSession.call(context, { renderBeforeBind: true }); + + expect(context.showStatus).not.toHaveBeenCalled(); + expect(context.openPendingPlanDecision).toHaveBeenCalledOnce(); + }); + + test("does not auto-open a pending plan from agent_settled", async () => { + const context: AgentSettledEventContext = { + isInitialized: true, + footer: { invalidate: vi.fn() }, + checkShutdownRequested: vi.fn().mockResolvedValue(undefined), + openPendingPlanDecision: vi.fn(), + }; + + await handleAgentSettled.call(context, { type: "agent_settled" }); + + expect(context.checkShutdownRequested).toHaveBeenCalledOnce(); + expect(context.openPendingPlanDecision).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts new file mode 100644 index 000000000..e1da8707e --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts @@ -0,0 +1,294 @@ +import { beforeAll, describe, expect, test, vi } from "vitest"; +import type { PlanArtifact } from "../src/core/plan-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { + createDecisionContext, + handlePlanCommand, + type OpenPlanDecisionContext, + openPendingPlanDecision, + PLAN, + type PlanDecisionSession, + type StreamingSubmitContext, + setupEditorSubmitHandler, + showPendingPlanDecision, +} from "./interactive-mode-plan-test-utils.ts"; + +beforeAll(() => initTheme("dark")); + +describe("InteractiveMode plan resolution", () => { + test("accepts the plan and routes the continuation through the runtime", async () => { + const context = createDecisionContext("Implement"); + context.session.resolvePlan.mockReturnValue({ message: "Implement the accepted plan." }); + + await showPendingPlanDecision.call(context, PLAN); + + expect(context.session.resolvePlan).toHaveBeenCalledWith(PLAN.id, "implement"); + expect(context.runtimeHost.promptPlanContinuation).toHaveBeenCalledWith( + { message: "Implement the accepted plan." }, + { + source: "interactive", + preflightResult: expect.any(Function), + }, + ); + expect(context.planDecisionAbortController).toBeUndefined(); + }); + + test.each([undefined, "Keep planning"])( + "leaves the plan pending when the selector returns %s", + async (selection) => { + const context = createDecisionContext(selection); + + await showPendingPlanDecision.call(context, PLAN); + + expect(context.session.resolvePlan).not.toHaveBeenCalled(); + expect(context.runtimeHost.promptPlanContinuation).not.toHaveBeenCalled(); + expect(context.suppressedPlanDecisionId).toBe(PLAN.id); + expect(context.showStatus).toHaveBeenCalledWith("Plan remains pending. Use /plan to reopen it."); + }, + ); + + test("discards the plan without submitting a continuation", async () => { + const context = createDecisionContext("Discard"); + + await showPendingPlanDecision.call(context, PLAN); + + expect(context.session.resolvePlan).toHaveBeenCalledWith(PLAN.id, "discard"); + expect(context.runtimeHost.promptPlanContinuation).not.toHaveBeenCalled(); + expect(context.showStatus).toHaveBeenCalledWith("Plan discarded; plan mode disabled"); + }); + + test("settles the selector without resolving the old session when invalidated", async () => { + const context = createDecisionContext(undefined); + context.showExtensionSelector = vi.fn( + (_title: string, _options: string[], opts?: { signal?: AbortSignal }) => + new Promise((resolve) => { + opts?.signal?.addEventListener("abort", () => resolve(undefined), { once: true }); + }), + ); + + const decision = showPendingPlanDecision.call(context, PLAN); + await vi.waitFor(() => expect(context.planDecisionAbortController).toBeInstanceOf(AbortController)); + context.planDecisionAbortController?.abort(); + await decision; + + expect(context.session.resolvePlan).not.toHaveBeenCalled(); + expect(context.planDecisionAbortController).toBeUndefined(); + }); + + test("retains the exact accepted continuation for /plan retry when preflight fails", async () => { + const context = createDecisionContext("Implement"); + const continuation = Object.freeze({ message: "Retry implementation." }); + context.session.pendingPlanContinuation = continuation; + context.session.resolvePlan.mockReturnValue(continuation); + context.runtimeHost.promptPlanContinuation.mockRejectedValue(new Error("No model is available")); + + await showPendingPlanDecision.call(context, PLAN); + + expect(context.editor.setText).not.toHaveBeenCalled(); + expect(context.session.pendingPlanContinuation).toBe(continuation); + expect(context.showError).toHaveBeenCalledWith( + "Plan acceptance persisted, but implementation could not start: No model is available Use /plan to retry.", + ); + + context.runtimeHost.promptPlanContinuation.mockImplementation( + (_continuation: unknown, options?: { preflightResult?: (success: boolean) => void }) => { + context.session.pendingPlanContinuation = undefined; + options?.preflightResult?.(true); + return Promise.resolve(); + }, + ); + await handlePlanCommand.call(context); + + expect(context.runtimeHost.promptPlanContinuation).toHaveBeenLastCalledWith(continuation, { + source: "interactive", + preflightResult: expect.any(Function), + }); + expect(context.session.pendingPlanContinuation).toBeUndefined(); + }); + + test("reports a resolved continuation that remains pending after persistence failure", async () => { + const context = createDecisionContext("Implement"); + const continuation = Object.freeze({ message: "Retry implementation." }); + context.session.pendingPlanContinuation = continuation; + context.session.resolvePlan.mockReturnValue(continuation); + context.runtimeHost.promptPlanContinuation.mockResolvedValue({}); + + await showPendingPlanDecision.call(context, PLAN); + + expect(context.session.pendingPlanContinuation).toBe(continuation); + expect(context.showError).toHaveBeenCalledWith( + "Plan acceptance persisted, but implementation did not start. Use /plan to retry.", + ); + }); + + test("retries the exact restored continuation from the current rebound session", async () => { + const context = createDecisionContext(undefined); + const staleSession = context.session; + const restoredContinuation = Object.freeze({ message: "Implement the restored accepted plan." }); + const reboundSession: PlanDecisionSession = { + pendingPlan: undefined, + pendingPlanContinuation: restoredContinuation, + resolvePlan: vi.fn(), + promptPlanContinuation: vi.fn(), + }; + context.session = reboundSession; + context.runtimeHost.promptPlanContinuation.mockImplementation( + (_continuation: unknown, options?: { preflightResult?: (success: boolean) => void }) => { + reboundSession.pendingPlanContinuation = undefined; + options?.preflightResult?.(true); + return Promise.resolve(); + }, + ); + + await handlePlanCommand.call(context); + + expect(staleSession.promptPlanContinuation).not.toHaveBeenCalled(); + expect(reboundSession.promptPlanContinuation).not.toHaveBeenCalled(); + expect(context.runtimeHost.promptPlanContinuation).toHaveBeenCalledWith(restoredContinuation, { + source: "interactive", + preflightResult: expect.any(Function), + }); + expect(reboundSession.pendingPlanContinuation).toBeUndefined(); + }); + + test.each(["on", "off"])("blocks /plan %s while an accepted continuation is pending", async (argument) => { + const context = createDecisionContext(undefined); + const continuation = Object.freeze({ message: "Implement the accepted plan." }); + context.session.pendingPlan = undefined; + context.session.pendingPlanContinuation = continuation; + + await handlePlanCommand.call(context, argument); + + expect(context.runtimeHost.promptPlanContinuation).not.toHaveBeenCalled(); + expect(context.showError).toHaveBeenCalledWith( + "Accepted plan implementation is waiting. Use /plan to retry it first.", + ); + expect(context.session.pendingPlanContinuation).toBe(continuation); + }); + + test("does not leave a handoff loader mounted when durable plan acceptance fails", async () => { + const context = createDecisionContext("Implement"); + context.session.resolvePlan.mockImplementation(() => { + throw new Error("persistence failed"); + }); + + await expect(showPendingPlanDecision.call(context, PLAN)).rejects.toThrow("persistence failed"); + + expect(context.mountEditorUi).not.toHaveBeenCalled(); + expect(context.planDecisionAbortController).toBeUndefined(); + }); + + test("suppresses automatic reopen but allows explicit reopen and a replacement plan", () => { + const context: OpenPlanDecisionContext = { + session: { pendingPlan: PLAN, isIdle: true, isStreaming: false }, + suppressedPlanDecisionId: PLAN.id, + showPendingPlanDecision: vi.fn().mockResolvedValue(undefined), + showError: vi.fn(), + renderer: { hasOverlayEntries: false }, + }; + + openPendingPlanDecision.call(context); + expect(context.showPendingPlanDecision).not.toHaveBeenCalled(); + + openPendingPlanDecision.call(context, { explicit: true }); + expect(context.showPendingPlanDecision).toHaveBeenCalledWith(PLAN); + expect(context.suppressedPlanDecisionId).toBeUndefined(); + + context.showPendingPlanDecision.mockClear(); + context.suppressedPlanDecisionId = PLAN.id; + const replacement = { ...PLAN, id: "plan-2" }; + context.session.pendingPlan = replacement; + openPendingPlanDecision.call(context); + expect(context.showPendingPlanDecision).toHaveBeenCalledWith(replacement); + expect(context.suppressedPlanDecisionId).toBeUndefined(); + }); + + test("does not replace another editor UI and opens once the normal editor is restored", () => { + const context: OpenPlanDecisionContext = { + session: { pendingPlan: PLAN, isIdle: true, isStreaming: false }, + activeEditorUi: { token: {}, cancel: vi.fn() }, + showPendingPlanDecision: vi.fn().mockResolvedValue(undefined), + showError: vi.fn(), + renderer: { hasOverlayEntries: false }, + }; + + openPendingPlanDecision.call(context); + expect(context.showPendingPlanDecision).not.toHaveBeenCalled(); + + context.activeEditorUi = undefined; + openPendingPlanDecision.call(context); + expect(context.showPendingPlanDecision).toHaveBeenCalledWith(PLAN); + }); + + test("does not open beneath a capturing overlay", () => { + const context: OpenPlanDecisionContext = { + session: { pendingPlan: PLAN, isIdle: true, isStreaming: false }, + showPendingPlanDecision: vi.fn().mockResolvedValue(undefined), + showError: vi.fn(), + renderer: { hasOverlayEntries: true }, + }; + + openPendingPlanDecision.call(context); + expect(context.showPendingPlanDecision).not.toHaveBeenCalled(); + }); + + test("does not open while prompt preflight still counts as streaming", () => { + const context: OpenPlanDecisionContext = { + session: { pendingPlan: PLAN, isIdle: true, isStreaming: true }, + showPendingPlanDecision: vi.fn().mockResolvedValue(undefined), + showError: vi.fn(), + renderer: { hasOverlayEntries: false }, + }; + + openPendingPlanDecision.call(context); + + expect(context.showPendingPlanDecision).not.toHaveBeenCalled(); + }); + + test("retries pending-plan admission after the last streaming submission preflight settles", async () => { + const session = { + isCompacting: false, + isStreaming: true, + isIdle: true, + pendingPlan: PLAN as PlanArtifact | undefined, + }; + const showPendingPlanDecision = vi.fn().mockResolvedValue(undefined); + const openContext: OpenPlanDecisionContext = { + session, + showPendingPlanDecision, + showError: vi.fn(), + renderer: { hasOverlayEntries: false }, + }; + const releases: Array<() => void> = []; + const context: StreamingSubmitContext = { + defaultEditor: {}, + editor: { addToHistory: vi.fn(), setText: vi.fn() }, + session, + promptUser: vi.fn( + () => + new Promise((resolve) => { + releases.push(resolve); + }), + ), + updatePendingMessagesDisplay: vi.fn(), + openPendingPlanDecision: vi.fn(() => openPendingPlanDecision.call(openContext)), + ui: { requestRender: vi.fn() }, + }; + setupEditorSubmitHandler.call(context); + if (!context.defaultEditor.onSubmit) throw new Error("Submit handler was not installed"); + + const first = context.defaultEditor.onSubmit("first queued message"); + const second = context.defaultEditor.onSubmit("second queued message"); + await vi.waitFor(() => expect(releases).toHaveLength(2)); + + releases[0]?.(); + await first; + expect(showPendingPlanDecision).not.toHaveBeenCalled(); + + session.isStreaming = false; + releases[1]?.(); + await second; + expect(showPendingPlanDecision).toHaveBeenCalledOnce(); + expect(showPendingPlanDecision).toHaveBeenCalledWith(PLAN); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts new file mode 100644 index 000000000..3fb7d9bfd --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts @@ -0,0 +1,420 @@ +import { type Component, Container, type Focusable, type TUI, TuiMainScreen } from "@earendil-works/diffex-tui"; +import { vi } from "vitest"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; +import type { PlanContinuation } from "../src/core/agent-session.ts"; +import type { PlanArtifact } from "../src/core/plan-mode.ts"; +import type { PlanEntry, SessionEntry } from "../src/core/session-manager.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; + +export const PLAN: PlanArtifact = { + id: "plan-1", + title: "Add native plan mode", + markdown: "1. Inspect the current session.\n2. Implement the approved changes.\n3. Run focused verification.", +}; + +export interface PlanDecisionSession { + pendingPlan: PlanArtifact | undefined; + pendingPlanContinuation?: PlanContinuation; + isStreaming?: boolean; + resolvePlan: ReturnType; + promptPlanContinuation: ReturnType; +} + +export interface PlanDecisionContext { + session: PlanDecisionSession; + runtimeHost: { promptPlanContinuation: ReturnType }; + planDecisionAbortController?: AbortController; + suppressedPlanDecisionId?: string; + showExtensionSelector: ReturnType; + showStatus: ReturnType; + showError: ReturnType; + mountEditorUi: ReturnType; + closeEditorUi: ReturnType; + startPlanContinuation: (continuation: PlanContinuation, onPromptOwned?: () => void) => Promise; + editor: { + getText: () => string; + setText: ReturnType; + }; + ui: { requestRender: ReturnType }; +} + +export const showPendingPlanDecision = Reflect.get(InteractiveMode.prototype, "showPendingPlanDecision") as ( + this: PlanDecisionContext, + plan: PlanArtifact, +) => Promise; + +export interface OpenPlanDecisionContext { + session: { pendingPlan: PlanArtifact | undefined; isIdle: boolean; isStreaming: boolean }; + planDecisionAbortController?: AbortController; + suppressedPlanDecisionId?: string; + activeEditorUi?: { token: object; cancel: () => void; exclusive?: boolean }; + showPendingPlanDecision: ReturnType; + showError: ReturnType; + renderer: { hasOverlayEntries: boolean }; +} + +export interface PlanSubmittedEventContext { + isInitialized: boolean; + footer: { invalidate: ReturnType }; + renderPlanArtifact: ReturnType; +} + +export const openPendingPlanDecision = Reflect.get(InteractiveMode.prototype, "openPendingPlanDecision") as ( + this: OpenPlanDecisionContext, + options?: { explicit?: boolean }, +) => void; + +export const startPlanContinuation = Reflect.get(InteractiveMode.prototype, "startPlanContinuation") as ( + this: PlanDecisionContext, + continuation: PlanContinuation, + onPromptOwned?: () => void, +) => Promise; + +export const handlePlanCommand = Reflect.get(InteractiveMode.prototype, "handlePlanCommand") as ( + this: PlanDecisionContext, + argument?: string, +) => Promise; + +export const handleEvent = Reflect.get(InteractiveMode.prototype, "handleEvent") as ( + this: PlanSubmittedEventContext, + event: { type: "plan_submitted"; plan: PlanArtifact }, +) => Promise; + +export interface RunFlowContext { + init: ReturnType; + version: string; + isInitialized: boolean; + options: { + initialMessage?: string; + initialImages?: unknown[]; + initialMessages?: string[]; + }; + session: { + modelRuntime: { getError: () => undefined }; + pendingPlan?: PlanArtifact; + pendingPlanContinuation?: PlanContinuation; + }; + checkForPackageUpdates: ReturnType; + checkTmuxKeyboardSetup: ReturnType; + maybeWarnAboutAnthropicSubscriptionAuth: ReturnType; + showNewVersionNotification: ReturnType; + showWarning: ReturnType; + showError: ReturnType; + showStatus: ReturnType; + updateTerminalTitle: ReturnType; + openPendingPlanDecision: ReturnType; + promptUser: ReturnType; + getUserInput: ReturnType; + editor: { getText: () => string; setText: ReturnType }; + ui: { requestRender: ReturnType }; +} + +export const runInteractive = Reflect.get(InteractiveMode.prototype, "run") as (this: RunFlowContext) => Promise; + +export interface RebindContext { + session: { pendingPlanContinuation?: PlanContinuation }; + activeEditorUi?: { preserveOnSessionRebind: boolean }; + unsubscribe?: () => void; + applyRuntimeSettings: ReturnType; + renderCurrentSessionState: ReturnType; + subscribeToAgent: ReturnType; + bindCurrentSessionExtensions: ReturnType; + updateAvailableProviderCount: ReturnType; + updateEditorBorderColor: ReturnType; + updateTerminalTitle: ReturnType; + showStatus: ReturnType; + openPendingPlanDecision: ReturnType; +} + +export const rebindCurrentSession = Reflect.get(InteractiveMode.prototype, "rebindCurrentSession") as ( + this: RebindContext, + options?: { renderBeforeBind?: boolean }, +) => Promise; + +export interface TreeNavigationStatusContext { + session: { pendingPlanContinuation?: PlanContinuation }; + showStatus: ReturnType; +} + +export const showTreeNavigationStatus = Reflect.get(InteractiveMode.prototype, "showTreeNavigationStatus") as ( + this: TreeNavigationStatusContext, +) => void; + +export interface StreamingSubmitContext { + defaultEditor: { onSubmit?: (text: string) => Promise }; + editor: { + addToHistory?: (text: string) => void; + setText: (text: string) => void; + }; + session: { + isCompacting: boolean; + isStreaming: boolean; + }; + promptUser: ReturnType; + updatePendingMessagesDisplay: ReturnType; + openPendingPlanDecision: ReturnType; + ui: { requestRender: ReturnType }; +} + +export const setupEditorSubmitHandler = Reflect.get(InteractiveMode.prototype, "setupEditorSubmitHandler") as ( + this: StreamingSubmitContext, +) => void; + +export interface AgentSettledEventContext { + isInitialized: boolean; + footer: { invalidate: ReturnType }; + checkShutdownRequested: ReturnType; + openPendingPlanDecision: ReturnType; +} + +export const handleAgentSettled = Reflect.get(InteractiveMode.prototype, "handleEvent") as ( + this: AgentSettledEventContext, + event: { type: "agent_settled" }, +) => Promise; + +export interface RenderPlanEntryContext { + pendingTools: Map; + settingsManager: { getShowCacheMissNotices: () => boolean }; + sessionManager: { getEntries: () => SessionEntry[] }; + session: { modelRuntime: unknown }; + renderPlanArtifact: ReturnType; + ui: { requestRender: ReturnType }; +} + +export const renderSessionItems = Reflect.get(InteractiveMode.prototype, "renderSessionItems") as ( + this: RenderPlanEntryContext, + items: PlanEntry[], +) => void; + +export interface RenderSessionEntriesContext { + session: { pendingPlan: PlanArtifact | undefined }; + renderSessionItems: ReturnType; + renderPlanArtifact: ReturnType; +} + +export const renderSessionEntries = Reflect.get(InteractiveMode.prototype, "renderSessionEntries") as ( + this: RenderSessionEntriesContext, + entries: SessionEntry[], +) => void; + +export interface EditorUiContext { + activeEditorUi?: { + token: object; + cancel: () => void; + exclusive: boolean; + preserveOnSessionRebind: boolean; + }; + editorContainer: Container; + editor: Component; + ui: TUI; + restoreEditorUi: () => void; + cancelActiveEditorUi: (restoreEditor?: boolean) => void; + closeEditorUi: (token: object) => boolean; + replaceEditorUi: ( + token: object, + component: Component, + focus: Component, + options?: { cancel?: () => void; preserveOnSessionRebind?: boolean }, + ) => boolean; + cancelEditorUi: (token: object) => void; + mountEditorUi: ( + component: Component, + focus: Component, + cancel: () => void, + options?: { exclusive?: boolean; preserveOnSessionRebind?: boolean }, + ) => object; +} + +export const restoreEditorUi = Reflect.get(InteractiveMode.prototype, "restoreEditorUi") as ( + this: EditorUiContext, +) => void; +export const cancelActiveEditorUi = Reflect.get(InteractiveMode.prototype, "cancelActiveEditorUi") as ( + this: EditorUiContext, + restoreEditor?: boolean, +) => void; +export const closeEditorUi = Reflect.get(InteractiveMode.prototype, "closeEditorUi") as ( + this: EditorUiContext, + token: object, +) => boolean; +export const replaceEditorUi = Reflect.get(InteractiveMode.prototype, "replaceEditorUi") as ( + this: EditorUiContext, + token: object, + component: Component, + focus: Component, + options?: { cancel?: () => void; preserveOnSessionRebind?: boolean }, +) => boolean; +export const cancelEditorUi = Reflect.get(InteractiveMode.prototype, "cancelEditorUi") as ( + this: EditorUiContext, + token: object, +) => void; +export const mountEditorUi = Reflect.get(InteractiveMode.prototype, "mountEditorUi") as ( + this: EditorUiContext, + component: Component, + focus: Component, + cancel: () => void, + options?: { exclusive?: boolean; preserveOnSessionRebind?: boolean }, +) => object; +export const showExtensionSelector = Reflect.get(InteractiveMode.prototype, "showExtensionSelector") as ( + this: EditorUiContext & { toggleToolOutputExpansion: () => void }, + title: string, + options: string[], + opts?: { signal?: AbortSignal }, +) => Promise; +export const showSelector = Reflect.get(InteractiveMode.prototype, "showSelector") as ( + this: EditorUiContext, + create: (done: () => void) => { component: Component; focus: Component; dispose?: () => void }, +) => void; +export const showExtensionCustom = Reflect.get(InteractiveMode.prototype, "showExtensionCustom") as ( + this: EditorUiContext & { keybindings: object }, + factory: (tui: TUI, theme: unknown, keybindings: object, done: (result: T) => void) => Component, + options?: { overlay?: boolean }, +) => Promise; + +export interface SessionRebindUiContext extends EditorUiContext { + session: { isStreaming: boolean }; + renderer: { hasOverlayEntries: boolean }; + cancelPendingPlanDecision: ReturnType; + clearExtensionTerminalInputListeners: ReturnType; + setExtensionFooter: ReturnType; + setExtensionHeader: ReturnType; + clearExtensionWidgets: ReturnType; + footerDataProvider: { clearExtensionStatuses: ReturnType }; + footer: { invalidate: ReturnType }; + autocompleteProviderWrappers: unknown[]; + setCustomEditorComponent: (factory: undefined) => void; + setupAutocompleteProvider: ReturnType; + defaultEditor: InputRecorder & { onExtensionShortcut?: (data: string) => void }; + updateTerminalTitle: ReturnType; + workingMessage?: string; + workingVisible: boolean; + setWorkingIndicator: ReturnType; + activeStatusIndicator?: { kind: string; setMessage: (message: string) => void }; + setHiddenThinkingLabel: ReturnType; + editorComponentFactory?: unknown; +} + +export const resetExtensionUI = Reflect.get(InteractiveMode.prototype, "resetExtensionUI") as ( + this: SessionRebindUiContext, + options?: { forSessionRebind?: boolean }, +) => void; +export const setCustomEditorComponent = Reflect.get(InteractiveMode.prototype, "setCustomEditorComponent") as ( + this: SessionRebindUiContext, + factory: undefined, +) => void; + +export interface LoginDialogContext extends EditorUiContext { + session: { model: undefined }; + loginProvider: ReturnType; + completeProviderAuthentication: ReturnType; + showError: ReturnType; +} + +export const showLoginDialog = Reflect.get(InteractiveMode.prototype, "showLoginDialog") as ( + this: LoginDialogContext, + providerId: string, + providerName: string, +) => Promise; + +export class InputRecorder implements Component, Focusable { + focused = false; + readonly inputs: string[] = []; + private text = ""; + + handleInput(data: string): void { + this.inputs.push(data); + } + + getText(): string { + return this.text; + } + + setText(text: string): void { + this.text = text; + } + + render(): string[] { + return ["EDITOR"]; + } + + invalidate(): void {} +} + +export function installEditorUiMethods( + context: T, +): T & EditorUiContext { + const editorUiContext = context as T & EditorUiContext; + editorUiContext.restoreEditorUi = () => restoreEditorUi.call(editorUiContext); + editorUiContext.cancelActiveEditorUi = (restoreEditor) => cancelActiveEditorUi.call(editorUiContext, restoreEditor); + editorUiContext.closeEditorUi = (token) => closeEditorUi.call(editorUiContext, token); + editorUiContext.replaceEditorUi = (token, component, focus, options) => + replaceEditorUi.call(editorUiContext, token, component, focus, options); + editorUiContext.cancelEditorUi = (token) => cancelEditorUi.call(editorUiContext, token); + editorUiContext.mountEditorUi = (component, focus, cancel, options) => + mountEditorUi.call(editorUiContext, component, focus, cancel, options); + return editorUiContext; +} + +export async function flushTui(ui: TUI, terminal: VirtualTerminal): Promise { + ui.requestRender(true); + await Promise.resolve(); + await terminal.waitForRender(); +} + +export function createProfileSelectionContext(width: number) { + const terminal = new VirtualTerminal(width, 24); + const ui = new TuiMainScreen(terminal); + const editor = new InputRecorder(); + const handoff = new InputRecorder(); + const editorContainer = new Container(); + editorContainer.addChild(editor); + ui.addChild(editorContainer); + ui.setFocus(editor); + const context = installEditorUiMethods({ + session: { isStreaming: false }, + editor, + editorContainer, + ui, + }); + const handoffCancel = vi.fn(); + const handoffToken = context.mountEditorUi(handoff, handoff, handoffCancel, { + exclusive: true, + preserveOnSessionRebind: true, + }); + return { terminal, ui, editor, handoff, context, handoffCancel, handoffToken }; +} + +export function createDecisionContext(selection: string | undefined): PlanDecisionContext { + const session: PlanDecisionSession = { + pendingPlan: PLAN, + resolvePlan: vi.fn(), + promptPlanContinuation: vi.fn(), + }; + const context: PlanDecisionContext = { + session, + runtimeHost: { + promptPlanContinuation: vi.fn( + (_continuation: unknown, options?: { preflightResult?: (success: boolean) => void }) => { + session.pendingPlanContinuation = undefined; + options?.preflightResult?.(true); + return Promise.resolve(); + }, + ), + }, + planDecisionAbortController: undefined, + suppressedPlanDecisionId: undefined, + showExtensionSelector: vi.fn().mockResolvedValue(selection), + showStatus: vi.fn(), + showError: vi.fn(), + mountEditorUi: vi.fn(() => ({})), + closeEditorUi: vi.fn(() => true), + startPlanContinuation: async () => {}, + editor: { + getText: () => "", + setText: vi.fn(), + }, + ui: { requestRender: vi.fn() }, + }; + context.startPlanContinuation = (continuation, onPromptOwned) => + startPlanContinuation.call(context, continuation, onPromptOwned); + return context; +} diff --git a/packages/coding-agent/test/plan-message.test.ts b/packages/coding-agent/test/plan-message.test.ts new file mode 100644 index 000000000..1359c3865 --- /dev/null +++ b/packages/coding-agent/test/plan-message.test.ts @@ -0,0 +1,127 @@ +import { visibleWidth } from "@earendil-works/diffex-tui"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import { + buildContextEntries, + buildSessionContext, + type PlanEntry, + type SessionEntry, +} from "../src/core/session-manager.ts"; +import { PlanMessageComponent } from "../src/modes/interactive/components/plan-message.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; +import { + handleEvent, + PLAN, + type PlanSubmittedEventContext, + type RenderPlanEntryContext, + type RenderSessionEntriesContext, + renderSessionEntries, + renderSessionItems, +} from "./interactive-mode-plan-test-utils.ts"; + +beforeAll(() => initTheme("dark")); + +describe("PlanMessageComponent", () => { + test("renders a submitted plan from the live transcript event", async () => { + const context: PlanSubmittedEventContext = { + isInitialized: true, + footer: { invalidate: vi.fn() }, + renderPlanArtifact: vi.fn(), + }; + + await handleEvent.call(context, { type: "plan_submitted", plan: PLAN }); + + expect(context.renderPlanArtifact).toHaveBeenCalledWith(PLAN); + }); + + test("restores persisted plan artifacts into the transcript", () => { + const context: RenderPlanEntryContext = { + pendingTools: new Map(), + settingsManager: { getShowCacheMissNotices: () => false }, + sessionManager: { getEntries: () => [] }, + session: { modelRuntime: {} }, + renderPlanArtifact: vi.fn(), + ui: { requestRender: vi.fn() }, + }; + const entry: PlanEntry = { + type: "plan", + id: "entry-1", + parentId: null, + timestamp: "2026-08-14T00:00:00.000Z", + plan: PLAN, + }; + + renderSessionItems.call(context, [entry]); + + expect(context.renderPlanArtifact).toHaveBeenCalledWith(PLAN); + expect(context.ui.requestRender).toHaveBeenCalledOnce(); + }); + + test("restores a pending plan omitted from compaction-aware transcript entries without duplicating present plans", () => { + const context: RenderSessionEntriesContext = { + session: { pendingPlan: PLAN }, + renderSessionItems: vi.fn(), + renderPlanArtifact: vi.fn(), + }; + const entry: PlanEntry = { + type: "plan", + id: "plan-entry-1", + parentId: "user-1", + timestamp: "2026-08-14T00:00:00.000Z", + plan: PLAN, + }; + const entries: SessionEntry[] = [ + { + type: "collaboration_mode_change", + id: "plan-mode-1", + parentId: null, + timestamp: "2026-08-14T00:00:00.000Z", + mode: "plan", + }, + { + type: "message", + id: "user-1", + parentId: "plan-mode-1", + timestamp: "2026-08-14T00:00:00.000Z", + message: { role: "user", content: "Create a plan.", timestamp: Date.now() }, + }, + entry, + { + type: "message", + id: "user-2", + parentId: entry.id, + timestamp: "2026-08-14T00:01:00.000Z", + message: { role: "user", content: "Keep this recent context.", timestamp: Date.now() }, + }, + { + type: "compaction", + id: "compaction-1", + parentId: "user-2", + timestamp: "2026-08-14T00:02:00.000Z", + summary: "Earlier context, including the pending plan, was compacted.", + firstKeptEntryId: "user-2", + tokensBefore: 20_000, + }, + ]; + const contextEntries = buildContextEntries(entries, "compaction-1"); + expect(contextEntries.some((candidate) => candidate.type === "plan")).toBe(false); + expect(buildSessionContext(entries, "compaction-1").pendingPlan).toEqual(PLAN); + + renderSessionEntries.call(context, contextEntries); + expect(context.renderPlanArtifact).toHaveBeenCalledWith(PLAN); + + context.renderPlanArtifact.mockClear(); + renderSessionEntries.call(context, [entry, ...contextEntries]); + expect(context.renderPlanArtifact).not.toHaveBeenCalled(); + }); + + test.each([80, 60])("renders normalized plan content within %i columns", (width) => { + const component = new PlanMessageComponent({ ...PLAN, title: "Add\n\u001b[2J native plan mode" }); + const lines = component.render(width); + + expect(stripAnsi(lines.join("\n"))).toContain("Plan: Add native plan mode"); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); +});