fix(claude): bound background task stop requests

This commit is contained in:
Merge Sim
2026-09-04 20:12:52 -07:00
parent 65e92d15fd
commit b10d7b18ae
6 changed files with 41 additions and 8 deletions
@@ -0,0 +1,26 @@
import type { Query } from '@anthropic-ai/claude-agent-sdk'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createClaudeControlSurface } from './claude-agent-sdk-control-requests'
afterEach(() => {
vi.useRealTimers()
})
describe('createClaudeControlSurface stopTask', () => {
it('bounds a lost reply and permits a later stop request', async () => {
vi.useFakeTimers()
const stopTask = vi
.fn<() => Promise<void>>()
.mockImplementationOnce(() => new Promise(() => {}))
.mockResolvedValueOnce()
const controls = createClaudeControlSurface({ stopTask } as unknown as Query)
const timedOut = expect(controls.stopTask('task-1', { timeoutMs: 25 })).rejects.toThrow(
'claude stop_task request timed out'
)
await vi.advanceTimersByTimeAsync(25)
await timedOut
await expect(controls.stopTask('task-2', { timeoutMs: 25 })).resolves.toBeUndefined()
expect(stopTask).toHaveBeenCalledTimes(2)
})
})
@@ -91,6 +91,7 @@ export type ClaudeControlSurface = {
settings: Parameters<Query['applyFlagSettings']>[0],
options?: ClaudeControlOptions
) => Promise<void>
stopTask: (taskId: string, options?: ClaudeControlOptions) => Promise<void>
supportedModels: (options?: ClaudeControlOptions) => Promise<unknown[]>
initializationResult: (options?: ClaudeControlOptions) => Promise<unknown>
getSettings: (options?: ClaudeControlOptions) => Promise<unknown>
@@ -135,6 +136,10 @@ export function createClaudeControlSurface(query: Query): ClaudeControlSurface {
() => query.applyFlagSettings(settings),
options?.timeoutMs
).then(() => {}),
stopTask: (taskId, options) =>
runClaudeControl('stop_task', () => query.stopTask(taskId), options?.timeoutMs).then(
() => {}
),
supportedModels: (options) =>
runClaudeControl('list_models', () => query.supportedModels(), options?.timeoutMs),
initializationResult: (options) =>
@@ -76,7 +76,6 @@ export type ClaudeStreamJsonConnection = ClaudeControlSurface & {
/** What the ladder has observed so far; read after a `close()` that returned false. */
readonly exitVerdict: ClaudeChildExitVerdict
send: (message: Record<string, unknown>) => Promise<void>
stopTask: (taskId: string) => Promise<void>
/** Resolves true after processless settlement, or root exit plus observed tree exit. */
close: () => Promise<boolean>
}
@@ -279,7 +278,6 @@ export async function openClaudeStreamJsonConnection(
} as const
},
send,
stopTask: (taskId) => session.stopTask(taskId),
close
}
}
@@ -128,11 +128,14 @@ describe('stopClaudeBackgroundTasks', () => {
{ task_id: 'task-bash', task_type: 'local_bash', description: 'bash' }
]
})
const stopTask = vi.fn(async (_taskId: string) => {})
const stopTask = vi.fn(async (_taskId: string, _options?: { timeoutMs?: number }) => {})
const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession
await expect(stopClaudeBackgroundTasks(session)).resolves.toEqual({ cancelled: true })
expect(stopTask.mock.calls.map(([taskId]) => taskId)).toEqual(['task-agent', 'task-bash'])
await expect(stopClaudeBackgroundTasks(session, 5_000)).resolves.toEqual({ cancelled: true })
expect(stopTask.mock.calls).toEqual([
['task-agent', { timeoutMs: 5_000 }],
['task-bash', { timeoutMs: 5_000 }]
])
})
it('stops issuing requests when ownership changes between tasks', async () => {
@@ -152,7 +155,7 @@ describe('stopClaudeBackgroundTasks', () => {
})
const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession
await stopClaudeBackgroundTasks(session, () => current)
await stopClaudeBackgroundTasks(session, undefined, () => current)
expect(stopTask).toHaveBeenCalledTimes(1)
})
})
@@ -45,6 +45,7 @@ export async function cancelClaudeTurn(
export async function stopClaudeBackgroundTasks(
session: ClaudeSession,
timeoutMs: number | undefined,
isCurrent: ClaudeTurnCancellationGuard = () => true
): Promise<{ cancelled: boolean }> {
const taskIds = session.backgroundTasks.stoppableTaskIds
@@ -54,7 +55,7 @@ export async function stopClaudeBackgroundTasks(
break
}
try {
await session.connection.stopTask(taskId)
await session.connection.stopTask(taskId, { timeoutMs })
cancelled = true
} catch (error) {
if (!(error instanceof ClaudeControlRequestError)) {
@@ -207,7 +207,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'] = (input) => {
const session = this.session(input.sessionId)
const acquisitionGeneration = session.acquisitionGeneration
return stopClaudeBackgroundTasks(session, () =>
return stopClaudeBackgroundTasks(session, this.deps.requestTimeoutMs, () =>
Boolean(
this.sessions.get(input.sessionId) === session &&
session.fence === input.fence &&