diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json index 3610e1f436..0f40e9d8b6 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json @@ -5,7 +5,8 @@ { "op": "promptAndCancel", "text": "Run two shell commands: wait for cancellation, then write skipped.txt.", - "afterUpdate": "tool_call" + "afterUpdate": "tool_call", + "waitForToolCallUpdate": "call_skipped" } ] } diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index ca00056d93..542b6d6df3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -39,8 +39,8 @@ export type { AgentUnderTest } from './launcher.ts' * * `promptAndCancel` starts a prompt without awaiting completion, waits until * the client observes the selected update (`agent_message_chunk` by default), - * then cancels and awaits completion. This keeps update/cancel order - * deterministic for fixtures that a plain `prompt` cannot drive. + * then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the + * step open for a terminal tool update that may follow the prompt response. */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } @@ -48,7 +48,12 @@ export type InputStep = | { op: 'newSessionExpectError'; additionalDirectories?: string[] } | { op: 'prompt'; text: string } | { op: 'promptExpectError'; text: string } - | { op: 'promptAndCancel'; text: string; afterUpdate?: 'agent_message_chunk' | 'tool_call' } + | { + op: 'promptAndCancel' + text: string + afterUpdate?: 'agent_message_chunk' | 'tool_call' + waitForToolCallUpdate?: string + } | { op: 'cancel' } | { op: 'setConfigOption'; configId: string; value: string } | { op: 'setConfigOptionExpectError'; configId: string; value: string } @@ -347,8 +352,13 @@ async function runStep( const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) const afterUpdate = step.afterUpdate ?? 'agent_message_chunk' await waitForUpdate(u => u.sessionUpdate === afterUpdate) + // Arm this before cancellation so a fast tool drain cannot outrun the waiter. + const toolCallUpdateDone = step.waitForToolCallUpdate === undefined + ? undefined + : waitForUpdate(u => u.sessionUpdate === 'tool_call_update' && u.toolCallId === step.waitForToolCallUpdate) await client.cancel({ sessionId }) await promptDone + if (toolCallUpdateDone !== undefined) await toolCallUpdateDone return } case 'cancel': { diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index dd8ca9ce92..43b5ee3fb3 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -35,6 +35,8 @@ interface Behavior { prompt?: 'respond' | 'error' | 'hang-until-cancel' /** Emit a tool call instead of a message chunk before parking a cancellable prompt. */ cancelAtToolCall?: boolean + /** Emit the parked tool call's terminal update after answering cancellation. */ + cancelToolCallUpdate?: boolean /** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */ permissionProbe?: boolean /** Echo the `DSH_SNAPSHOT_*` env the harness set as a chunk (spec-side env-plumbing assertions). */ @@ -247,6 +249,19 @@ function handleFrame(frame: Record): void { const parked = parkedPromptId parkedPromptId = null respond(parked, { stopReason: 'cancelled' }) + if (behavior.cancelToolCallUpdate === true) { + send({ + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'call_fake_1', + status: 'failed', + }, + }, + }) + } } return default: diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index be03b27c78..3d174b3c3e 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -334,14 +334,26 @@ describe('runScenario', () => { expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled')) }) - it('promptAndCancel can wait for a tool call before cancelling', { timeout: 20_000 }, async () => { - const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel', cancelAtToolCall: true }) + it('promptAndCancel can bracket cancellation with tool-call updates', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + prompt: 'hang-until-cancel', + cancelAtToolCall: true, + cancelToolCallUpdate: true, + }) const result = await runScenario( - { steps: [...boot, { op: 'promptAndCancel', text: 'hang', afterUpdate: 'tool_call' }] }, + { + steps: [...boot, { + op: 'promptAndCancel', + text: 'hang', + afterUpdate: 'tool_call', + waitForToolCallUpdate: 'call_fake_1', + }], + }, { agent: AGENT, mode: 'replay', fixtureFile }, ) expect(result.rawStdout).toContain('"sessionUpdate":"tool_call"') expect(result.rawStdout.indexOf('"sessionUpdate":"tool_call"')).toBeLessThan(result.rawStdout.indexOf('cancelled')) + expect(result.rawStdout.indexOf('cancelled')).toBeLessThan(result.rawStdout.indexOf('"sessionUpdate":"tool_call_update"')) }) it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => {