diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 671b5d134..1b59739e0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,12 +1,13 @@ # Changelog ## [Unreleased] - ### Breaking Changes - Changed `HashlineEdit` API from `old: string | string[]` / `new: string | string[]` to `src: string` / `dst: string`; multi-line content uses `\n`-separated strings, empty string `""` for insert/delete operations - Replaced `edit.patchMode` boolean setting with `edit.mode` enum; existing `edit.patchMode: true` configurations should use `edit.mode: patch` - Changed `getEditModelVariants()` return type from `Record` to `Record` +- Removed `after` field from `HashlineEdit`; insert-after is now expressed via open range syntax `src: "5:ab.."` +- Changed `HashlineEdit.src` from newline-separated line ref lists to range syntax: `"5:ab"` (single), `"5:ab..9:ef"` (range), `"5:ab.."` (insert after); comma and newline-separated lists are no longer supported ### Added @@ -33,6 +34,8 @@ ### Changed +- Enhanced bash executor to properly handle abort signals by registering abort event listeners and cleaning up resources in finally block +- Improved bash tool error handling to distinguish between user-initiated aborts (via AbortSignal) and other cancellations, throwing ToolAbortError for aborted requests - Enhanced MCP request handling to propagate abort signals through HTTP, SSE, and stdio transports with proper cleanup - Improved stdio transport request handling to use Promise.withResolvers for cleaner async flow and better abort signal integration - Updated HTTP transport to combine operation abort signals with timeout signals using AbortSignal.any() for unified cancellation diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index c87d1f2a7..53c52b4b6 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -73,50 +73,64 @@ export async function executeBash(command: string, options?: BashExecutorOptions shellSessions.set(sessionKey, shellSession); } - const result = await shellSession.run( - { - command: finalCommand, - cwd: options?.cwd, - env: options?.env, - timeoutMs: options?.timeout, - signal: options?.signal, - }, - (err, chunk) => { - if (!err) { - enqueueChunk(chunk); - } - }, - ); - - await pendingChunks; - - // Handle timeout - if (result.timedOut) { - const annotation = options?.timeout - ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds` - : "Command timed out"; - return { - exitCode: undefined, - cancelled: true, - ...(await sink.dump(annotation)), - }; - } - - // Handle cancellation - if (result.cancelled) { - return { - exitCode: undefined, - cancelled: true, - ...(await sink.dump("Command cancelled")), - }; - } - - // Normal completion - return { - exitCode: result.exitCode, - cancelled: false, - ...(await sink.dump()), + const signal = options?.signal; + const abortHandler = () => { + shellSession.abort(signal?.reason instanceof Error ? signal.reason.message : undefined); }; + if (signal) { + signal.addEventListener("abort", abortHandler, { once: true }); + } + + try { + const result = await shellSession.run( + { + command: finalCommand, + cwd: options?.cwd, + env: options?.env, + timeoutMs: options?.timeout, + signal, + }, + (err, chunk) => { + if (!err) { + enqueueChunk(chunk); + } + }, + ); + + await pendingChunks; + + // Handle timeout + if (result.timedOut) { + const annotation = options?.timeout + ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds` + : "Command timed out"; + return { + exitCode: undefined, + cancelled: true, + ...(await sink.dump(annotation)), + }; + } + + // Handle cancellation + if (result.cancelled) { + return { + exitCode: undefined, + cancelled: true, + ...(await sink.dump("Command cancelled")), + }; + } + + // Normal completion + return { + exitCode: result.exitCode, + cancelled: false, + ...(await sink.dump()), + }; + } finally { + if (signal) { + signal.removeEventListener("abort", abortHandler); + } + } } finally { await pendingChunks; } diff --git a/packages/coding-agent/src/mcp/transports/http.ts b/packages/coding-agent/src/mcp/transports/http.ts index c1ced75f8..a1427b684 100644 --- a/packages/coding-agent/src/mcp/transports/http.ts +++ b/packages/coding-agent/src/mcp/transports/http.ts @@ -103,7 +103,11 @@ export class HttpTransport implements MCPTransport { } } - async request(method: string, params?: Record, options?: MCPRequestOptions): Promise { + async request( + method: string, + params?: Record, + options?: MCPRequestOptions, + ): Promise { if (!this.#connected) { throw new Error("Transport not connected"); } diff --git a/packages/coding-agent/src/mcp/transports/stdio.ts b/packages/coding-agent/src/mcp/transports/stdio.ts index 94c041ca6..136a30eaf 100644 --- a/packages/coding-agent/src/mcp/transports/stdio.ts +++ b/packages/coding-agent/src/mcp/transports/stdio.ts @@ -146,7 +146,11 @@ export class StdioTransport implements MCPTransport { this.onClose?.(); } - async request(method: string, params?: Record, options?: MCPRequestOptions): Promise { + async request( + method: string, + params?: Record, + options?: MCPRequestOptions, + ): Promise { if (!this.#connected || !this.#process?.stdin) { throw new Error("Transport not connected"); } diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 83e1c7940..2286caad1 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -20,7 +20,7 @@ import type { OutputMeta } from "./output-meta"; import { allocateOutputArtifact, createTailBuffer } from "./output-utils"; import { resolveToCwd } from "./path-utils"; import { formatBytes, replaceTabs, wrapBrackets } from "./render-utils"; -import { ToolError } from "./tool-errors"; +import { ToolAbortError, ToolError } from "./tool-errors"; import { toolResult } from "./tool-result"; import { DEFAULT_MAX_BYTES } from "./truncate"; @@ -135,6 +135,9 @@ export class BashTool implements AgentTool { // Handle errors const result = await executeBash(command, executorOptions); if (result.cancelled) { + if (signal?.aborted) { + throw new ToolAbortError(result.output || "Command aborted"); + } throw new ToolError(result.output || "Command aborted"); }