mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user