From 2efa728d2ee90ef597626e96b1e28ef2b279f07c Mon Sep 17 00:00:00 2001 From: Michael Yu Date: Mon, 27 Jul 2026 18:00:24 +0800 Subject: [PATCH] fix(coding-agent): support concurrent user bash cancellation (#7103) --- .../coding-agent/src/core/agent-session.ts | 15 +++-- .../agent-session-bash-persistence.test.ts | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a688528ce..96bded989 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -334,7 +334,7 @@ export class AgentSession { private _retryAttempt = 0; // Bash execution state - private _bashAbortController: AbortController | undefined = undefined; + private readonly _bashAbortControllers = new Set(); private _pendingBashMessages: BashExecutionMessage[] = []; // Extension system @@ -2766,7 +2766,8 @@ export class AgentSession { onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations }, ): Promise { - this._bashAbortController = new AbortController(); + const abortController = new AbortController(); + this._bashAbortControllers.add(abortController); // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) const prefix = this.settingsManager.getShellCommandPrefix(); @@ -2783,14 +2784,14 @@ export class AgentSession { onChunk?.(delta); this._emit({ type: "bash_execution_update", id: options?.id, delta }); }, - signal: this._bashAbortController.signal, + signal: abortController.signal, }, ); this.recordBashResult(command, result, options); return result; } finally { - this._bashAbortController = undefined; + this._bashAbortControllers.delete(abortController); } } @@ -2828,12 +2829,14 @@ export class AgentSession { * Cancel running bash command. */ abortBash(): void { - this._bashAbortController?.abort(); + for (const abortController of [...this._bashAbortControllers]) { + abortController.abort(); + } } /** Whether a bash command is currently running */ get isBashRunning(): boolean { - return this._bashAbortController !== undefined; + return this._bashAbortControllers.size > 0; } /** Whether there are pending bash messages waiting to be flushed */ diff --git a/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts b/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts index e1b8534a0..0e9db222e 100644 --- a/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts +++ b/packages/coding-agent/test/suite/agent-session-bash-persistence.test.ts @@ -10,6 +10,24 @@ function getEntryTypes(harness: Harness): string[] { return harness.sessionManager.getEntries().map((entry) => entry.type); } +interface ControlledBashInvocation { + signal: AbortSignal | undefined; + finish: () => void; +} + +function createControlledBashOperations(invocations: ControlledBashInvocation[]): BashOperations { + return { + exec: async (_command, _cwd, options) => { + return await new Promise<{ exitCode: number | null }>((resolve) => { + invocations.push({ + signal: options.signal, + finish: () => resolve({ exitCode: 0 }), + }); + }); + }, + }; +} + describe("AgentSession bash and persistence characterization", () => { const harnesses: Harness[] = []; @@ -132,6 +150,52 @@ describe("AgentSession bash and persistence characterization", () => { expect(harness.session.isBashRunning).toBe(false); }); + it("keeps newer bash execution tracked when an older execution finishes", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const invocations: ControlledBashInvocation[] = []; + const operations = createControlledBashOperations(invocations); + + const firstBash = harness.session.executeBash("first", undefined, { operations }); + const secondBash = harness.session.executeBash("second", undefined, { operations }); + + invocations[0].finish(); + const firstResult = await firstBash; + const runningAfterFirstSettles = harness.session.isBashRunning; + + harness.session.abortBash(); + const secondWasAborted = invocations[1].signal?.aborted; + invocations[1].finish(); + const secondResult = await secondBash; + + expect(firstResult.cancelled).toBe(false); + expect(runningAfterFirstSettles).toBe(true); + expect(secondWasAborted).toBe(true); + expect(secondResult.cancelled).toBe(true); + expect(harness.session.isBashRunning).toBe(false); + }); + + it("aborts all active bash executions", async () => { + const harness = await createHarness(); + harnesses.push(harness); + const invocations: ControlledBashInvocation[] = []; + const operations = createControlledBashOperations(invocations); + + const firstBash = harness.session.executeBash("first", undefined, { operations }); + const secondBash = harness.session.executeBash("second", undefined, { operations }); + + harness.session.abortBash(); + const abortedSignals = invocations.map((invocation) => invocation.signal?.aborted); + for (const invocation of invocations) { + invocation.finish(); + } + const results = await Promise.all([firstBash, secondBash]); + + expect(abortedSignals).toEqual([true, true]); + expect(results.map((result) => result.cancelled)).toEqual([true, true]); + expect(harness.session.isBashRunning).toBe(false); + }); + it("persists user, assistant, toolResult, and custom messages in order", async () => { const echoTool: AgentTool = { name: "echo",