diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 859a361f8..fddc637f5 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -317,6 +317,19 @@ function unregisterSpinnerBlock(block: ToolExecutionComponent): void { } } +/** Stop the shared spinner ticker and drop every registered live block. + * Called on interactive-mode teardown so a stray live block cannot keep the + * process-wide 80ms interval alive past shutdown (lingering event-loop + * handles pin the process; cf. `postmortem.quit`). Test files that assert on + * ticker arming also use this to start from a clean slate. */ +export function stopSharedSpinnerTicker(): void { + liveSpinnerBlocks.clear(); + if (sharedSpinnerTimer) { + clearInterval(sharedSpinnerTimer); + sharedSpinnerTimer = undefined; + } +} + // Stable per-instance counter so each tool execution's inline images get a // graphics id that survives child re-creation (the image budget keys off it). let toolExecutionInstanceSeq = 0; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 3837eaca1..22eb1ec46 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -166,7 +166,7 @@ import type { HookInputComponent } from "./components/hook-input"; import type { HookSelectorComponent, HookSelectorSlider } from "./components/hook-selector"; import { type PlanReviewAnnotationState, PlanReviewOverlay } from "./components/plan-review-overlay"; import { StatusLineComponent } from "./components/status-line"; -import type { ToolExecutionHandle } from "./components/tool-execution"; +import { stopSharedSpinnerTicker, type ToolExecutionHandle } from "./components/tool-execution"; import { TranscriptContainer } from "./components/transcript-container"; import { WelcomeComponent, type LspServerInfo as WelcomeLspServerInfo } from "./components/welcome"; import { BtwController } from "./controllers/btw-controller"; @@ -4160,6 +4160,9 @@ export class InteractiveMode implements InteractiveModeContext { this.#stopLoadingAnimation(false); } this.#cleanupMicAnimation(); + // Stop the shared tool-spinner ticker: a live block missed by per-component + // stopAnimation would otherwise keep an 80ms interval pinning the process. + stopSharedSpinnerTicker(); this.#liveCommandController.dispose(); this.#cancelTodoAutoClearTimer(); this.#cancelObserverUiSyncTimer(); diff --git a/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts b/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts index f425c62fc..6feda7822 100644 --- a/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts +++ b/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as ai from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; @@ -12,8 +12,19 @@ import { createAssistantMessage } from "./helpers/agent-session-setup"; let session: AgentSession | undefined; let authStorage: AuthStorage | undefined; +// Earlier full-suite files that boot main() in ACP/RPC mode set PI_NO_TITLE=1 +// process-wide (main.ts) and never restore it; maybeStartTitleGeneration gates +// on it, so these tests would silently skip titling and time out. +let previousNoTitle: string | undefined; + +beforeEach(() => { + previousNoTitle = Bun.env.PI_NO_TITLE; + delete Bun.env.PI_NO_TITLE; +}); afterEach(async () => { + if (previousNoTitle === undefined) delete Bun.env.PI_NO_TITLE; + else Bun.env.PI_NO_TITLE = previousNoTitle; vi.restoreAllMocks(); await session?.dispose(); authStorage?.close(); diff --git a/packages/coding-agent/test/modes/components/tool-execution-spinner.test.ts b/packages/coding-agent/test/modes/components/tool-execution-spinner.test.ts index 6a8d95b2c..52932a85d 100644 --- a/packages/coding-agent/test/modes/components/tool-execution-spinner.test.ts +++ b/packages/coding-agent/test/modes/components/tool-execution-spinner.test.ts @@ -1,7 +1,8 @@ -import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { stripVTControlCharacters } from "node:util"; import { SPINNER_RENDER_INTERVAL_MS, + stopSharedSpinnerTicker, ToolExecutionComponent, } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container"; @@ -17,6 +18,13 @@ describe("ToolExecutionComponent live preview spinners", () => { await initTheme(); }); + // Earlier test files may leak live blocks (components never stopAnimation'd), + // which keeps the shared ticker armed on a REAL interval and makes these + // fake-timer assertions observe a pre-existing timer instead of a fresh one. + beforeEach(() => { + stopSharedSpinnerTicker(); + }); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks();