fix: expose abort signal to extensions closes #2660
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `Agent.signal` to expose the active abort signal for the current turn, allowing callers to forward cancellation into nested async work ([#2660](https://github.com/badlogic/pi-mono/issues/2660))
|
||||
|
||||
## [0.63.1] - 2026-03-27
|
||||
|
||||
## [0.63.0] - 2026-03-27
|
||||
|
||||
@@ -371,6 +371,11 @@ export class Agent {
|
||||
this._state.messages = [];
|
||||
}
|
||||
|
||||
/** The current abort signal, or undefined when the agent is not streaming. */
|
||||
get signal(): AbortSignal | undefined {
|
||||
return this.abortController?.signal;
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `ctx.signal` to `ExtensionContext` and wired it to the active agent turn so extension handlers can forward cancellation into nested model calls, `fetch()`, and other abort-aware work ([#2660](https://github.com/badlogic/pi-mono/issues/2660))
|
||||
|
||||
## [0.63.1] - 2026-03-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -629,6 +629,8 @@ Fired after tool execution finishes and before `tool_execution_end` plus the fin
|
||||
- Each handler sees the latest result after previous handler changes
|
||||
- Handlers can return partial patches (`content`, `details`, or `isError`); omitted fields keep their current values
|
||||
|
||||
Use `ctx.signal` for nested async work inside the handler. This lets Esc cancel model calls, `fetch()`, and other abort-aware operations started by the extension.
|
||||
|
||||
```typescript
|
||||
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
@@ -640,6 +642,12 @@ pi.on("tool_result", async (event, ctx) => {
|
||||
// event.details is typed as BashToolDetails
|
||||
}
|
||||
|
||||
const response = await fetch("https://example.com/summarize", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: event.content }),
|
||||
signal: ctx.signal,
|
||||
});
|
||||
|
||||
// Modify result:
|
||||
return { content: [...], details: {...}, isError: false };
|
||||
});
|
||||
@@ -759,6 +767,31 @@ ctx.sessionManager.getLeafId() // Current leaf entry ID
|
||||
|
||||
Access to models and API keys.
|
||||
|
||||
### ctx.signal
|
||||
|
||||
The current agent abort signal, or `undefined` when no agent turn is active.
|
||||
|
||||
Use this for abort-aware nested work started by extension handlers, for example:
|
||||
- `fetch(..., { signal: ctx.signal })`
|
||||
- model calls that accept `signal`
|
||||
- file or process helpers that accept `AbortSignal`
|
||||
|
||||
`ctx.signal` is typically defined during active turn events such as `tool_call`, `tool_result`, `message_update`, and `turn_end`.
|
||||
It is usually `undefined` in idle or non-turn contexts such as session events, extension commands, and shortcuts fired while pi is idle.
|
||||
|
||||
```typescript
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
const response = await fetch("https://example.com/api", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(event),
|
||||
signal: ctx.signal,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return { details: data };
|
||||
});
|
||||
```
|
||||
|
||||
### ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()
|
||||
|
||||
Control flow helpers.
|
||||
|
||||
@@ -2219,6 +2219,7 @@ export class AgentSession {
|
||||
{
|
||||
getModel: () => this.model,
|
||||
isIdle: () => !this.isStreaming,
|
||||
getSignal: () => this.agent.signal,
|
||||
abort: () => this.abort(),
|
||||
hasPendingMessages: () => this.pendingMessageCount > 0,
|
||||
shutdown: () => {
|
||||
|
||||
@@ -208,6 +208,7 @@ export class ExtensionRunner {
|
||||
private errorListeners: Set<ExtensionErrorListener> = new Set();
|
||||
private getModel: () => Model<any> | undefined = () => undefined;
|
||||
private isIdleFn: () => boolean = () => true;
|
||||
private getSignalFn: () => AbortSignal | undefined = () => undefined;
|
||||
private waitForIdleFn: () => Promise<void> = async () => {};
|
||||
private abortFn: () => void = () => {};
|
||||
private hasPendingMessagesFn: () => boolean = () => false;
|
||||
@@ -265,6 +266,7 @@ export class ExtensionRunner {
|
||||
// Context actions (required)
|
||||
this.getModel = contextActions.getModel;
|
||||
this.isIdleFn = contextActions.isIdle;
|
||||
this.getSignalFn = contextActions.getSignal;
|
||||
this.abortFn = contextActions.abort;
|
||||
this.hasPendingMessagesFn = contextActions.hasPendingMessages;
|
||||
this.shutdownHandler = contextActions.shutdown;
|
||||
@@ -541,6 +543,7 @@ export class ExtensionRunner {
|
||||
return getModel();
|
||||
},
|
||||
isIdle: () => this.isIdleFn(),
|
||||
signal: this.getSignalFn(),
|
||||
abort: () => this.abortFn(),
|
||||
hasPendingMessages: () => this.hasPendingMessagesFn(),
|
||||
shutdown: () => this.shutdownHandler(),
|
||||
|
||||
@@ -274,6 +274,8 @@ export interface ExtensionContext {
|
||||
model: Model<any> | undefined;
|
||||
/** Whether the agent is idle (not streaming) */
|
||||
isIdle(): boolean;
|
||||
/** The current abort signal, or undefined when the agent is not streaming. */
|
||||
signal: AbortSignal | undefined;
|
||||
/** Abort the current agent operation */
|
||||
abort(): void;
|
||||
/** Whether there are queued messages waiting */
|
||||
@@ -1391,6 +1393,7 @@ export interface ExtensionActions {
|
||||
export interface ExtensionContextActions {
|
||||
getModel: () => Model<any> | undefined;
|
||||
isIdle: () => boolean;
|
||||
getSignal: () => AbortSignal | undefined;
|
||||
abort: () => void;
|
||||
hasPendingMessages: () => boolean;
|
||||
shutdown: () => void;
|
||||
|
||||
@@ -1280,6 +1280,7 @@ export class InteractiveMode {
|
||||
modelRegistry: this.session.modelRegistry,
|
||||
model: this.session.model,
|
||||
isIdle: () => !this.session.isStreaming,
|
||||
signal: this.session.agent.signal,
|
||||
abort: () => this.session.abort(),
|
||||
hasPendingMessages: () => this.session.pendingMessageCount > 0,
|
||||
shutdown: () => {
|
||||
|
||||
@@ -71,6 +71,7 @@ describe("ExtensionRunner", () => {
|
||||
const extensionContextActions: ExtensionContextActions = {
|
||||
getModel: () => undefined,
|
||||
isIdle: () => true,
|
||||
getSignal: () => undefined,
|
||||
abort: () => {},
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: () => {},
|
||||
@@ -399,6 +400,26 @@ describe("ExtensionRunner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("context creation", () => {
|
||||
it("exposes the current abort signal on ExtensionContext", async () => {
|
||||
const result = await discoverAndLoadExtensions([], tempDir, tempDir);
|
||||
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry);
|
||||
const controller = new AbortController();
|
||||
|
||||
runner.bindCore(extensionActions, {
|
||||
...extensionContextActions,
|
||||
getSignal: () => controller.signal,
|
||||
});
|
||||
|
||||
const ctx = runner.createContext();
|
||||
expect(ctx.signal).toBe(controller.signal);
|
||||
expect(ctx.signal?.aborted).toBe(false);
|
||||
|
||||
controller.abort();
|
||||
expect(ctx.signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("calls error listeners when handler throws", async () => {
|
||||
const extCode = `
|
||||
|
||||
@@ -11,6 +11,7 @@ function createContext(tokens: number | null, compact = vi.fn()): ExtensionConte
|
||||
modelRegistry: {} as ExtensionContext["modelRegistry"],
|
||||
model: undefined,
|
||||
isIdle: () => true,
|
||||
signal: undefined,
|
||||
abort: vi.fn(),
|
||||
hasPendingMessages: () => false,
|
||||
shutdown: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user