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.
This commit is contained in:
Merge Sim
2026-09-09 12:57:26 -07:00
parent 88204cdfa5
commit f1de6e3490
7 changed files with 112 additions and 12 deletions
@@ -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()
@@ -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 } : {})
})
}
@@ -80,9 +80,7 @@ describe('NativeChatBackgroundTasksStatus row glyphs', () => {
const monitorGlyph = glyphClassFor('monitor')
cleanup()
const { container } = render(<AgentStateDot state="monitoring" size="md" title={null} />)
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(
<NativeChatBackgroundTasksStatus
tasks={[
{ id: 'fore-1', kind: 'agent', description: 'in-turn subagent', stoppable: false },
{ id: 'back-1', kind: 'agent', description: 'backgrounded subagent' }
]}
supportsTaskStop
supportsStopAll
stoppingTaskIds={new Set()}
stoppingAll={false}
onStop={onStop}
/>
)
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 })
@@ -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"
>
<BackgroundTaskKindIcon
kind={task.kind}
className="text-muted-foreground"
/>
<BackgroundTaskKindIcon kind={task.kind} className="text-muted-foreground" />
<span className="min-w-0 flex-1 break-words">{label}</span>
{props.supportsTaskStop ? (
{props.supportsTaskStop && backgroundTaskStoppable(task) ? (
<Button
type="button"
variant="ghost"
+5
View File
@@ -60,6 +60,11 @@ export type AgentSessionBackgroundTask = {
id: string
kind: 'agent' | 'workflow' | 'command' | 'monitor' | 'unknown'
description?: string
/** Whether this row's own stop can act on it. Absent means yes: every host
* that predates this field published only backgrounded, stoppable rows, and
* a client that read absence as "not stoppable" would hide a working control
* on those hosts. A row the host cannot target sends `false`. */
stoppable?: boolean
}
export type AgentSessionBackgroundTaskState = {
@@ -87,6 +87,46 @@ describe('structured agent session reducer', () => {
expect(updated.items).toBe(initial.items)
})
it("republishes when only a row's stoppability changes", () => {
// A row losing its stop is the whole difference between an honest control
// and a dead one, so it must not be dropped as an equal state.
const backgroundTasks = {
state: 'monitoring' as const,
supportsTaskStop: true,
tasks: [{ id: 'task-1', kind: 'agent' as const }]
}
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
event: {
type: 'snapshot',
sessionId: 'session-a',
fence: 1,
page: { ...hydrationPage([]), backgroundTasks }
}
})
const updated = reduceStructuredAgentSession(initial, {
type: 'event',
event: {
type: 'batch',
sessionId: 'session-a',
batch: {
cursor: { epoch: 'epoch-a', sequence: 0 },
items: [],
removedItemIds: [],
submissions: []
},
backgroundTasks: {
...backgroundTasks,
tasks: [{ id: 'task-1', kind: 'agent' as const, stoppable: false }]
}
}
})
expect(updated.backgroundTasks?.tasks).toEqual([
{ id: 'task-1', kind: 'agent', stoppable: false }
])
})
it('uses the bounded hydration page pagination boundary', () => {
const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
type: 'event',
@@ -74,7 +74,8 @@ function backgroundTaskStatesEqual(
(task, index) =>
task.id === right.tasks?.[index]?.id &&
task.kind === right.tasks[index]?.kind &&
task.description === right.tasks[index]?.description
task.description === right.tasks[index]?.description &&
task.stoppable === right.tasks[index]?.stoppable
)
}