From f1de6e3490ba8522afa0192ccdf8064a8fa47560 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Wed, 9 Sep 2026 12:57:26 -0700 Subject: [PATCH] fix(native-chat): withhold the row stop Claude foreground work cannot honour The strip now publishes foreground rows, but `stoppableTaskIds` still filters on `backgrounded`, so `stopClaudeBackgroundTasks` resolved an empty target list and returned `{ cancelled: false }` that no renderer reads: the user clicked "Stop Subagent" and nothing ever happened. Carry stoppability per row instead of widening the stop to a target the SDK has no way to reach. `AgentSessionBackgroundTask.stoppable` is absent-means-yes, so hosts that predate it keep their working control, Claude emits `false` only on foreground rows, and the strip hides that row's button the same way it already hides the stop-all a provider cannot honour. --- .../claude-background-task-tracker.test.ts | 29 ++++++++++++-- .../claude/claude-background-task-tracker.ts | 3 ++ .../NativeChatBackgroundTasksStatus.test.tsx | 28 +++++++++++-- .../NativeChatBackgroundTasksStatus.tsx | 16 +++++--- src/shared/agent-session-wire.ts | 5 +++ .../structured-agent-session-reducer.test.ts | 40 +++++++++++++++++++ .../structured-agent-session-reducer.ts | 3 +- 7 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/main/claude/claude-background-task-tracker.test.ts b/src/main/claude/claude-background-task-tracker.test.ts index afac5a1a23f..7d17ee086ca 100644 --- a/src/main/claude/claude-background-task-tracker.test.ts +++ b/src/main/claude/claude-background-task-tracker.test.ts @@ -55,8 +55,8 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.state).toEqual({ state: 'monitoring', tasks: [ - { id: 'agent-1', kind: 'agent' }, - { id: 'agent-2', kind: 'agent' } + { id: 'agent-1', kind: 'agent', stoppable: false }, + { id: 'agent-2', kind: 'agent', stoppable: false } ] }) // The turn IS the outcome of work the provider marked foreground. @@ -66,6 +66,29 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.stoppableTaskIds).toEqual([]) }) + it('marks a foreground row not stoppable and leaves a backgrounded row alone', () => { + // `stopTask` has no foreground target, so the row must not offer a Stop that + // would silently do nothing. A backgrounded row stays untouched on the wire. + const tracker = new ClaudeBackgroundTaskTracker() + tracker.observe({ type: 'user' }, true) + tracker.observe( + system('task_started', { + task_id: 'fore-1', + task_type: 'local_agent', + is_backgrounded: false + }) + ) + tracker.observe( + system('task_started', { task_id: 'back-1', task_type: 'local_agent', is_backgrounded: true }) + ) + + expect(tracker.state?.tasks).toEqual([ + { id: 'fore-1', kind: 'agent', stoppable: false }, + { id: 'back-1', kind: 'agent' } + ]) + expect(tracker.stoppableTaskIds).toEqual(['back-1']) + }) + it('uses an explicit background update for a foreground task and ignores progress alone', () => { const tracker = new ClaudeBackgroundTaskTracker() tracker.observe({ type: 'user' }, true) @@ -80,7 +103,7 @@ describe('ClaudeBackgroundTaskTracker', () => { tracker.observe(system('task_progress', { task_id: 'task-1', description: 'still working' })) ).toBe(false) // Live while the turn runs, then retired by that turn's `result`. - expect(tracker.state?.tasks).toEqual([{ id: 'task-1', kind: 'command' }]) + expect(tracker.state?.tasks).toEqual([{ id: 'task-1', kind: 'command', stoppable: false }]) tracker.observe(result()) expect(tracker.state).toBeNull() diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index 7667f5c27c3..b5bd6e13d26 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -265,6 +265,9 @@ export class ClaudeBackgroundTaskTracker { details.push({ id, kind: task.kind, + // Foreground work is not a target `stopTask` accepts, so the row says so + // rather than drawing a Stop that would silently do nothing. + ...(task.backgrounded ? {} : { stoppable: false }), ...(task.description ? { description: task.description } : {}) }) } diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx index f038bb1fcd9..435012539c5 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.test.tsx @@ -80,9 +80,7 @@ describe('NativeChatBackgroundTasksStatus row glyphs', () => { const monitorGlyph = glyphClassFor('monitor') cleanup() const { container } = render() - const dotGlyph = lucideGlyphName( - container.querySelector('svg')?.getAttribute('class') ?? '' - ) + const dotGlyph = lucideGlyphName(container.querySelector('svg')?.getAttribute('class') ?? '') expect(monitorGlyph).not.toBe('') expect(monitorGlyph).toBe(dotGlyph) }) @@ -122,6 +120,30 @@ describe('NativeChatBackgroundTasksStatus stop affordances', () => { expect(screen.getByLabelText('Stop background tasks')).toBeInTheDocument() }) + it('withholds a row stop the host reported it cannot act on', () => { + // Claude publishes foreground rows with `stoppable: false`: the session + // accepts targeted stops, but not for this row. + const onStop = vi.fn() + render( + + ) + fireEvent.click(screen.getByRole('button', { expanded: false })) + + expect(screen.getByText('in-turn subagent')).toBeInTheDocument() + expect(screen.queryByLabelText('Stop in-turn subagent')).not.toBeInTheDocument() + expect(screen.getByLabelText('Stop backgrounded subagent')).toBeInTheDocument() + }) + it('offers no stop at all when the provider exposes none', () => { // Codex: a Stop button here would be a control that cannot act. renderStrip({ supportsTaskStop: false, supportsStopAll: false }) diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index 9d3e899a359..4a3f968d512 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -13,8 +13,17 @@ function backgroundTaskLabel(task: AgentSessionBackgroundTask): string { return task.description || backgroundTaskKindLabel(task.kind) } +/** Absent means stoppable: a host that predates the field published only rows + * its stop could act on. Only an explicit `false` withholds the control. */ +function backgroundTaskStoppable(task: AgentSessionBackgroundTask): boolean { + return task.stoppable !== false +} + export function NativeChatBackgroundTasksStatus(props: { tasks: readonly AgentSessionBackgroundTask[] + /** Whether the host accepts a targeted stop at all. A row still withholds its + * own button when the row itself reports it cannot be stopped — same rule as + * `supportsStopAll`: never a button that cannot act. */ supportsTaskStop: boolean /** False when the provider exposes no honest stop at all; the fallback * control is hidden rather than offering a button that cannot act. */ @@ -75,12 +84,9 @@ export function NativeChatBackgroundTasksStatus(props: { key={task.id} className="flex min-w-0 items-center gap-2 text-foreground/80" > - + {label} - {props.supportsTaskStop ? ( + {props.supportsTaskStop && backgroundTaskStoppable(task) ? (