Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,11 @@ unsubscribe();

##### `abort(): Promise<void>`

Abort the currently processing message in this session.
Abort the currently processing message in this session. This also aborts the `AbortSignal` passed to any in-flight tool handlers (see [Cancelling Tool Handlers](#cancelling-tool-handlers)).

##### `cancelToolCall(toolCallId: string): boolean`

Cooperatively cancel a single in-flight tool handler by aborting the `AbortSignal` on its `ToolInvocation`, without aborting the broader agentic loop. Returns `true` if a matching in-flight tool call was found and signaled, `false` otherwise.

##### `getEvents(): Promise<SessionEvent[]>`

Expand Down Expand Up @@ -503,6 +507,35 @@ defineTool("lookup_issue", {
});
```

#### Cancelling Tool Handlers

Long-running tool handlers can opt in to cooperative cancellation. Each handler's `ToolInvocation` carries a standard [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that aborts when `session.abort()` (which cancels the whole agentic loop) or `session.cancelToolCall(toolCallId)` (which cancels a single in-flight handler) is invoked. Forward it to abortable APIs or check `signal.aborted`:

```ts
defineTool("fetch_data", {
description: "Fetch a large payload",
parameters: z.object({ url: z.string() }),
handler: async ({ url }, { signal }) => {
// The fetch is aborted automatically when the session/tool is cancelled
const res = await fetch(url, { signal });
return await res.text();
},
});
```

Cancel a specific in-flight handler without aborting the rest of the turn:

```ts
session.on("tool.execution_start", (event) => {
setTimeout(() => {
// Returns true if a matching in-flight handler was signaled
session.cancelToolCall(event.data.toolCallId);
}, 5000);
});
```

Handlers that ignore the signal continue to run to completion, so existing handlers keep working unchanged.

### Commands

Register slash commands so that users of the CLI's TUI can invoke custom actions via `/commandName`. Each command has a `name`, optional `description`, and a `handler` called when the user executes it.
Expand Down
69 changes: 69 additions & 0 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export class CopilotSession {
private typedEventHandlers: Map<SessionEventType, Set<(event: SessionEvent) => void>> =
new Map();
private toolHandlers: Map<string, ToolHandler> = new Map();
private inFlightToolCalls: Map<string, AbortController> = new Map();
private canvases: Map<string, Canvas> = new Map();
private commandHandlers: Map<string, CommandHandler> = new Map();
private permissionHandler?: PermissionHandler;
Expand Down Expand Up @@ -563,12 +564,15 @@ export class CopilotSession {
traceparent?: string,
tracestate?: string
): Promise<void> {
const abortController = new AbortController();
this.inFlightToolCalls.set(toolCallId, abortController);
try {
const rawResult = await handler(args, {
sessionId: this.sessionId,
toolCallId,
toolName,
arguments: args,
signal: abortController.signal,
traceparent,
tracestate,
});
Expand All @@ -593,6 +597,12 @@ export class CopilotSession {
}
// Connection lost or RPC error — nothing we can do
}
} finally {
// Only clear if this is still the controller for this toolCallId;
// guards against a recycled toolCallId from a later invocation.
if (this.inFlightToolCalls.get(toolCallId) === abortController) {
this.inFlightToolCalls.delete(toolCallId);
}
}
}

Expand Down Expand Up @@ -1170,6 +1180,9 @@ export class CopilotSession {
* ```
*/
async disconnect(): Promise<void> {
// Abort any in-flight tool handlers so they can release resources.
this._abortInFlightToolCalls();
this.inFlightToolCalls.clear();
await this.connection.sendRequest("session.destroy", {
sessionId: this.sessionId,
});
Expand Down Expand Up @@ -1209,11 +1222,67 @@ export class CopilotSession {
* ```
*/
async abort(): Promise<void> {
// Cooperatively cancel any in-flight tool handlers that opted in to the
// AbortSignal exposed on their ToolInvocation. Handlers that ignore the
// signal continue to run to completion.
this._abortInFlightToolCalls();
await this.connection.sendRequest("session.abort", {
sessionId: this.sessionId,
});
}

/**
* Cooperatively cancels a single in-flight tool handler by aborting the
* `AbortSignal` on its `ToolInvocation`, without aborting the broader
* agentic loop.
*
* This only affects handlers that opted in to the signal (e.g. by passing
* it to `fetch`, `child_process.spawn`, or checking `signal.aborted`).
* Handlers that ignore the signal continue to run to completion.
*
* @param toolCallId - The `toolCallId` of the in-flight tool invocation to cancel
* @returns `true` if a matching in-flight tool call was found and signaled, `false` otherwise
*
* @example
* ```typescript
* const session = await client.createSession({
* tools: [
* defineTool("fetch_data", {
* handler: async (args, { signal }) => {
* const res = await fetch(args.url, { signal });
* return await res.text();
* },
* }),
* ],
* });
*
* session.on((event) => {
* if (event.type === "tool.execution_start") {
* // Cancel a specific tool call after a deadline
* setTimeout(() => session.cancelToolCall(event.data.toolCallId), 5000);
* }
* });
* ```
*/
cancelToolCall(toolCallId: string): boolean {
const controller = this.inFlightToolCalls.get(toolCallId);
if (!controller) {
return false;
}
controller.abort();
return true;
}
Comment on lines +1267 to +1277

/**
* Aborts the AbortSignal for every in-flight tool handler.
* @internal
*/
private _abortInFlightToolCalls(): void {
for (const controller of this.inFlightToolCalls.values()) {
controller.abort();
}
}

/**
* Change the model for this session.
* The new model takes effect for the next message. Conversation history is preserved.
Expand Down
9 changes: 9 additions & 0 deletions nodejs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,15 @@ export interface ToolInvocation {
toolCallId: string;
toolName: string;
arguments: unknown;
/**
* An `AbortSignal` that aborts when `session.abort()` or
* `session.cancelToolCall(toolCallId)` is invoked while this handler is
* in flight. Handlers may opt in to cooperative cancellation by forwarding
* it to abortable APIs (`fetch(url, { signal })`, `child_process.spawn`,
* etc.) or by checking `signal.aborted`. Handlers that ignore it continue
* to run to completion, preserving existing behavior.
*/
signal: AbortSignal;
Comment on lines 475 to +486

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping signal required on purpose for cross-SDK consistency — the same field is non-optional in the Python, Go, .NET, Rust, and Java equivalents, and the SDK always injects a live signal at runtime, so handlers never have to null-check it. The only friction is for consumers who hand-construct/mock ToolInvocation in tests, which is a one-line addition (signal: new AbortController().signal). Given it's a new field on a handler-input type rather than a return type, the runtime guarantee felt worth the minor upgrade cost.

/** W3C Trace Context traceparent from the CLI's execute_tool span. */
traceparent?: string;
/** W3C Trace Context tracestate from the CLI's execute_tool span. */
Expand Down
11 changes: 10 additions & 1 deletion nodejs/test/e2e/abort.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ describe("Abort", async () => {
releaseToolResolve = resolve;
});

let signalAbortedResolve!: (value: void) => void;
const signalAborted = new Promise<void>((resolve) => {
signalAbortedResolve = resolve;
});

const session = await client.createSession({
onPermissionRequest: approveAll,
tools: [
Expand All @@ -107,8 +112,9 @@ describe("Abort", async () => {
parameters: z.object({
value: z.string().describe("Value to analyze"),
}),
handler: async ({ value }) => {
handler: async ({ value }, { signal }) => {
toolStartedResolve(value);
signal.addEventListener("abort", () => signalAbortedResolve());
return await releaseTool;
},
}),
Expand All @@ -127,6 +133,9 @@ describe("Abort", async () => {
// Abort while the tool is running
await session.abort();

// The handler's AbortSignal should fire as a result of session.abort()
await withTimeout(signalAborted, 10_000, "tool handler AbortSignal");

// Release the tool so its task doesn't leak
releaseToolResolve("RELEASED_AFTER_ABORT");

Expand Down