diff --git a/src/main/claude/claude-agent-sdk-control-requests.test.ts b/src/main/claude/claude-agent-sdk-control-requests.test.ts new file mode 100644 index 00000000000..f76650d8151 --- /dev/null +++ b/src/main/claude/claude-agent-sdk-control-requests.test.ts @@ -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>() + .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) + }) +}) diff --git a/src/main/claude/claude-agent-sdk-control-requests.ts b/src/main/claude/claude-agent-sdk-control-requests.ts index 6bd396413fa..71498f28421 100644 --- a/src/main/claude/claude-agent-sdk-control-requests.ts +++ b/src/main/claude/claude-agent-sdk-control-requests.ts @@ -91,6 +91,7 @@ export type ClaudeControlSurface = { settings: Parameters[0], options?: ClaudeControlOptions ) => Promise + stopTask: (taskId: string, options?: ClaudeControlOptions) => Promise supportedModels: (options?: ClaudeControlOptions) => Promise initializationResult: (options?: ClaudeControlOptions) => Promise getSettings: (options?: ClaudeControlOptions) => Promise @@ -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) => diff --git a/src/main/claude/claude-stream-json-connection.ts b/src/main/claude/claude-stream-json-connection.ts index c5db83a9aed..dd6bbc8a5eb 100644 --- a/src/main/claude/claude-stream-json-connection.ts +++ b/src/main/claude/claude-stream-json-connection.ts @@ -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) => Promise - stopTask: (taskId: string) => Promise /** Resolves true after processless settlement, or root exit plus observed tree exit. */ close: () => Promise } @@ -279,7 +278,6 @@ export async function openClaudeStreamJsonConnection( } as const }, send, - stopTask: (taskId) => session.stopTask(taskId), close } } diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index 11105b4b53f..a90cb7908ba 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -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) }) }) diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index eb284c882a7..d216304c311 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -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)) { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index e2a1166d355..40b12ecf74d 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -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 &&