fix(coding-agent): support concurrent user bash cancellation (#7103)

This commit is contained in:
Michael Yu 2026-07-27 18:00:24 +08:00 • committed by GitHub
parent c2275d6703
commit 2efa728d2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 6 deletions

View file

@ -334,7 +334,7 @@ export class AgentSession {
private _retryAttempt = 0;
// Bash execution state
private _bashAbortController: AbortController | undefined = undefined;
private readonly _bashAbortControllers = new Set<AbortController>();
private _pendingBashMessages: BashExecutionMessage[] = [];
// Extension system
@ -2766,7 +2766,8 @@ export class AgentSession {
onChunk?: (chunk: string) => void,
options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations },
): Promise<BashResult> {
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 */

View file

@ -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",