From 0f1339b23885f575c81d56d2c77ef981810378a8 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:52:29 -0700 Subject: [PATCH 1/2] test(coding-agent): cover plan resolution outcomes Verify durable acceptance, retries, selector suppression, cancellation, streaming admission, and transcript plan rendering. --- .../interactive-mode-plan-resolution.test.ts | 347 ++++++++++++++++++ .../coding-agent/test/plan-message.test.ts | 127 +++++++ 2 files changed, 474 insertions(+) create mode 100644 packages/coding-agent/test/interactive-mode-plan-resolution.test.ts create mode 100644 packages/coding-agent/test/plan-message.test.ts 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..8cf13d703 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-plan-resolution.test.ts @@ -0,0 +1,347 @@ +import { beforeAll, describe, expect, test, vi } from "vitest"; +import type { RuntimePlanContinuationPromptOptions } from "../src/core/agent-session-runtime.ts"; +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, + PROFILE_PROPOSAL, + 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", + profileSelectionResolver: expect.any(Function), + preflightResult: expect.any(Function), + }, + ); + expect(context.planDecisionAbortController).toBeUndefined(); + }); + + test("keeps the runtime harness-switch notice visible after routed implementation starts", async () => { + const context = createDecisionContext("Implement"); + const continuation = Object.freeze({ message: "Implement the accepted plan." }); + const showStatus = vi.fn((_message: string) => {}); + context.showStatus = showStatus; + context.session.pendingPlanContinuation = continuation; + context.session.resolvePlan.mockReturnValue(continuation); + context.runtimeHost.promptPlanContinuation.mockImplementation( + (_continuation: unknown, options?: { preflightResult?: (success: boolean) => void }) => { + showStatus('Created harness "Plan implementation" using the Coding prompt profile.'); + context.session.pendingPlanContinuation = undefined; + options?.preflightResult?.(true); + return Promise.resolve({ routing: "selected", activeProfileId: "coding" }); + }, + ); + + await showPendingPlanDecision.call(context, PLAN); + + expect(context.showStatus).toHaveBeenLastCalledWith( + 'Created harness "Plan implementation" using the Coding prompt profile.', + ); + expect(context.showError).not.toHaveBeenCalled(); + }); + + 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({ routing: "selected", activeProfileId: "coding" }); + }, + ); + await handlePlanCommand.call(context); + + expect(context.runtimeHost.promptPlanContinuation).toHaveBeenLastCalledWith(continuation, { + source: "interactive", + profileSelectionResolver: expect.any(Function), + 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({ + routing: "bypassed", + activeProfileId: "general", + }); + + 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("reports an intentional profile selection cancellation without a red implementation error", async () => { + const context = createDecisionContext("Implement"); + const continuation = Object.freeze({ message: "Retry implementation." }); + context.session.pendingPlanContinuation = continuation; + context.session.resolvePlan.mockReturnValue(continuation); + context.runtimeHost.promptPlanContinuation.mockImplementation( + async (_continuation: unknown, options?: RuntimePlanContinuationPromptOptions) => { + await options?.profileSelectionResolver?.(PROFILE_PROPOSAL, new AbortController().signal); + throw new Error("Runtime rejected the cancelled selection."); + }, + ); + + await showPendingPlanDecision.call(context, PLAN); + + expect(context.showStatus).toHaveBeenCalledWith( + "Profile selection cancelled. Accepted plan implementation is waiting; use /plan to retry.", + ); + expect(context.showError).not.toHaveBeenCalled(); + expect(context.session.pendingPlanContinuation).toBe(continuation); + }); + + 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({ routing: "selected", activeProfileId: "coding" }); + }, + ); + + await handlePlanCommand.call(context); + + expect(staleSession.promptPlanContinuation).not.toHaveBeenCalled(); + expect(reboundSession.promptPlanContinuation).not.toHaveBeenCalled(); + expect(context.runtimeHost.promptPlanContinuation).toHaveBeenCalledWith(restoredContinuation, { + source: "interactive", + profileSelectionResolver: expect.any(Function), + 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/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); + } + }); +}); From aae4a3f63405c31c9b75bdd75f194a44829bffe8 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 15 Aug 2026 16:52:38 -0700 Subject: [PATCH 2/2] test(coding-agent): cover plan lifecycle recovery Exercise startup serialization, restored continuations, session rebinds, navigation notices, and settled-event behavior. --- .../interactive-mode-plan-lifecycle.test.ts | 280 ++++++++++++++++++ .../test/interactive-mode-plan-test-utils.ts | 1 + 2 files changed, 281 insertions(+) create mode 100644 packages/coding-agent/test/interactive-mode-plan-lifecycle.test.ts 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-test-utils.ts b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts index 27b490057..7bf71f808 100644 --- a/packages/coding-agent/test/interactive-mode-plan-test-utils.ts +++ b/packages/coding-agent/test/interactive-mode-plan-test-utils.ts @@ -129,6 +129,7 @@ export interface RunFlowContext { showWarning: ReturnType; showError: ReturnType; showStatus: ReturnType; + updateTerminalTitle: ReturnType; openPendingPlanDecision: ReturnType; promptUser: ReturnType; getUserInput: ReturnType;