diff --git a/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts index d9deef24121..34bc7916164 100644 --- a/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts +++ b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts @@ -56,7 +56,7 @@ describe('#9040 worktree dot attributes spinner titles to the launched agent', ( }) // Why: pins the #9647 gate — spinner attribution needs a launch identity, so a - // frozen spinner frame left by an exited agent still cannot spin the dot forever. + // spinner in a tab no agent was launched in cannot spin the dot. it('stays active for a spinner title with no launch identity', () => { const status = getWorktreeStatus( [{ id: 'tab-1', title: '⠐ Review branch for regressions' }], @@ -78,6 +78,30 @@ describe('#9040 worktree dot attributes spinner titles to the launched agent', ( }) }) +// Why: launchAgent usually clears on exit (clearTabLaunchAgent via +// resolveLaunchedAgentExitEvidence), but not on every path — a hookless remote agent has +// no completion hook and no shell-foreground producer, so its launch identity can outlive +// it and a later ora-style spinner then reads as working. Documented trade-off, not an +// oversight — the row builder has behaved this way since #9647. Pinned so it stays a +// deliberate choice rather than drifting silently. +describe('#9040 spinner attribution trade-off is bounded', () => { + it('over-reports a non-agent spinner in a tab an agent was launched in', () => { + const tab = { + id: 'tab-1', + title: '⠋ Progress: resolved 42', + launchAgent: 'claude' + } satisfies Partial + + expect(getWorktreeStatus([tab], [], livePtyMap('tab-1'))).toBe('working') + // Bound 1: the same title cannot spin a tab with no launch identity. + expect(getWorktreeStatus([{ id: 'tab-1', title: tab.title }], [], livePtyMap('tab-1'))).toBe( + 'active' + ) + // Bound 2: a dead PTY drops out regardless of launch identity. + expect(getWorktreeStatus([tab], [], {})).toBe('inactive') + }) +}) + // Why: the gate's stated purpose is that the dot never spins with no matching sidebar // row. Claude's spinner title must reach the same dot/row agreement a named provider // already gets — no better, and no worse.