diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7571e2a01..3d9100e83 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,6 +21,7 @@ - Improved error reporting for `omp tiny-models download` by displaying the actual worker-side download error. - Resolved status inconsistencies between `/extensions`, `/mcp list`, and the dashboard, ensuring MCP server states, allowlists/denylists, and configuration files (like `mcp.json`) stay fully synchronized. - Improved branch-mode task merges to preserve the agent's original commit history (messages and authors) and fixed a bug where merges were rejected due to unrelated dirty changes in the parent checkout. +- Fixed the `Working…` loader staying gone for the rest of a parent turn when a long-running tool (e.g. a `task` subagent) finished inside a transient overlay window (auto-snapcompact, auto-context-full, auto-retry). Those overlays null the working loader on start and the overlay-end handler is the only restorer keyed off the missing loader; if the subagent's `tool_execution_end` lands while the overlay is still active (or its end handler errored before re-arming), the spinner stayed gone until the next turn even though the parent kept streaming. `tool_execution_end` now mirrors the `tool_execution_update` self-heal so the working loader survives a subagent completing inside the overlay window ([#3858](https://github.com/can1357/oh-my-pi/issues/3858)). ## [16.2.6] - 2026-06-29 diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 46edcb592..b9673169d 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -890,6 +890,17 @@ export class EventController { } async #handleToolExecutionEnd(event: Extract): Promise { + // A long-running tool (`task` subagent, async bash poll, …) is the next + // streaming event on the parent session when an inner transient overlay + // (auto-compaction / auto-retry) clears the status container between this + // tool's start and end. `auto_*_end` is the overlay-side restorer; for any + // path that nulls the loader without a follow-up overlay-end (e.g. an + // errored overlay teardown, or the subagent finishing inside the overlay + // window), the next tool event is the only restore point before the turn + // settles. `tool_execution_update` already self-heals — keep + // `tool_execution_end` symmetric so the spinner survives a subagent + // completing inside the overlay window (#3858). + this.#ensureWorkingLoaderWhileStreaming(); if (event.toolName === "read") { if (this.#inlineReadToolImages(event.toolCallId, event.result)) { const component = this.ctx.pendingTools.get(event.toolCallId); diff --git a/packages/coding-agent/test/event-controller-error-banner.test.ts b/packages/coding-agent/test/event-controller-error-banner.test.ts index 97284b7df..eb982eaa4 100644 --- a/packages/coding-agent/test/event-controller-error-banner.test.ts +++ b/packages/coding-agent/test/event-controller-error-banner.test.ts @@ -299,6 +299,47 @@ describe("EventController working loader reconciliation", () => { expect(ctx.ensureLoadingAnimation).toHaveBeenCalledTimes(1); }); + it("self-heals missing working loader when a task subagent finishes mid-turn (#3858)", async () => { + // `task` subagents run inside the parent's streaming turn. While the task is + // running a transient overlay (auto-compaction / auto-retry) can drop the + // working loader by clearing the status container, and the overlay's end + // handler is the only restorer keyed off the missing loader. If the task + // finishes between the overlay's start and end (or any other branch where + // the loader was nulled without a follow-up overlay-end), `tool_execution_end` + // is the next streaming event that lands and must heal the loader, mirroring + // the `tool_execution_update` reconciler. Without this the spinner stays + // gone for the remainder of the parent turn even though the agent keeps + // streaming (the user-visible regression in #3858). + const { controller, ctx } = createFixture(); + (ctx.viewSession as unknown as { isStreaming: boolean }).isStreaming = true; + + await controller.handleEvent({ + type: "tool_execution_end", + toolCallId: "task-1", + toolName: "task", + isError: false, + result: { content: [{ type: "text", text: "ok" }], details: {} }, + } as Extract); + + expect(ctx.ensureLoadingAnimation).toHaveBeenCalledTimes(1); + }); + + it("does not restore the working loader while an overlay loader (auto-retry) owns the status container at tool_execution_end", async () => { + const { controller, ctx } = createFixture(); + ctx.retryLoader = { stop: vi.fn() } as unknown as InteractiveModeContext["retryLoader"]; + (ctx.viewSession as unknown as { isStreaming: boolean }).isStreaming = true; + + await controller.handleEvent({ + type: "tool_execution_end", + toolCallId: "task-2", + toolName: "task", + isError: false, + result: { content: [{ type: "text", text: "ok" }], details: {} }, + } as Extract); + + expect(ctx.ensureLoadingAnimation).not.toHaveBeenCalled(); + }); + it("keeps transient retry status exclusive while a retry loader is visible", async () => { const { controller, ctx } = createFixture(); ctx.retryLoader = { stop: vi.fn() } as unknown as InteractiveModeContext["retryLoader"]; diff --git a/packages/coding-agent/test/event-controller-todo-reminder.test.ts b/packages/coding-agent/test/event-controller-todo-reminder.test.ts index 15b1b5cbc..666b74fe4 100644 --- a/packages/coding-agent/test/event-controller-todo-reminder.test.ts +++ b/packages/coding-agent/test/event-controller-todo-reminder.test.ts @@ -21,6 +21,11 @@ function createContext() { updateEditorTopBorder: vi.fn(), clearPinnedError: vi.fn(), ensureLoadingAnimation: vi.fn(), + // `viewSession.isStreaming` is read by `#ensureWorkingLoaderWhileStreaming`, + // which runs at the top of `tool_execution_end` (and other streaming-event + // handlers). Leaving it false matches the implicit assumption in this + // fixture: the todo HUD lifecycle is independent of the working loader. + viewSession: { isStreaming: false }, todoReminderContainer, setTodos: vi.fn(), present,