Merge PR #6806: fix(extensions): cancel timed-out handler dialogs (@roboomp)

This commit is contained in:
can1357
2026-07-27 15:57:46 +02:00
7 changed files with 372 additions and 56 deletions
+1
View File
@@ -12,6 +12,7 @@
- Fixed MCP resources with custom URI schemes being treated as missing filesystem paths. `read` and `omp read` now resolve server-advertised native resource URIs such as `ags://capabilities/current-host`, while preserving the existing `mcp://<resource-uri>` form.
- Fixed custom LSP servers sending `languageId: "plaintext"` for extensions outside the built-in language map by honoring an optional per-server `languageId` in `lsp.json` for disk and in-memory document opens ([#6800](https://github.com/can1357/oh-my-pi/issues/6800)).
- Fixed interactive extension confirmations ignoring `dialogOptions`, and cancelled handler-owned dialogs when the extension watchdog times out so stale approval UI cannot outlive a blocked tool call ([#6805](https://github.com/can1357/oh-my-pi/issues/6805)).
- Fixed Python cell errors (`$` commands and the eval tool) leaking runner-internal traceback frames. Cell syntax errors now render as the bare caret display with a `<cell>` filename instead of a `_handle_request_async`/`ast.parse` stack dump, and runtime tracebacks start at user code, matching the Ruby runner's user-frame filtering.
- Dropped unavailable forced tool choices through the queue rejection lifecycle and discarded their remaining sequence yields so a skipped force cannot disable tools on the next request ([#6543](https://github.com/can1357/oh-my-pi/pull/6543) by [@paralin](https://github.com/paralin)).
### Fixed
@@ -37,6 +37,7 @@ import type {
ExtensionRuntime,
ExtensionShortcut,
ExtensionUIContext,
ExtensionUIDialogOptions,
InputEvent,
InputEventResult,
MessageRenderer,
@@ -105,6 +106,50 @@ function handlerTimeoutForEvent(eventType: string): number {
const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
const EXTENSION_HANDLER_ABORTED = Symbol("extensionHandlerAborted");
function attachHandlerSignal(
dialogOptions: ExtensionUIDialogOptions | undefined,
handlerSignal: AbortSignal,
): ExtensionUIDialogOptions {
if (!dialogOptions) return { signal: handlerSignal };
if (!dialogOptions.signal) return { ...dialogOptions, signal: handlerSignal };
if (dialogOptions.signal === handlerSignal) return dialogOptions;
return { ...dialogOptions, signal: AbortSignal.any([dialogOptions.signal, handlerSignal]) };
}
function createHandlerUIContext(ui: ExtensionUIContext, handlerSignal: AbortSignal): ExtensionUIContext {
const askDialog = ui.askDialog;
const dialogMethods = {
select: (title, options, dialogOptions) =>
ui.select(title, options, attachHandlerSignal(dialogOptions, handlerSignal)),
confirm: (title, message, dialogOptions) =>
ui.confirm(title, message, attachHandlerSignal(dialogOptions, handlerSignal)),
input: (title, placeholder, dialogOptions) =>
ui.input(title, placeholder, attachHandlerSignal(dialogOptions, handlerSignal)),
askDialog: askDialog
? (questions, dialogOptions) =>
askDialog.call(ui, questions, attachHandlerSignal(dialogOptions, handlerSignal))
: undefined,
editor: (title, prefill, dialogOptions, editorOptions) =>
ui.editor(title, prefill, attachHandlerSignal(dialogOptions, handlerSignal), editorOptions),
} satisfies Pick<ExtensionUIContext, "select" | "confirm" | "input" | "askDialog" | "editor">;
const delegatedMethods = new Map<PropertyKey, unknown>();
return new Proxy(ui, {
get(target, property) {
if (Object.hasOwn(dialogMethods, property)) {
return Reflect.get(dialogMethods, property, dialogMethods);
}
const cached = delegatedMethods.get(property);
if (cached) return cached;
const value: unknown = Reflect.get(target, property, target);
if (typeof value !== "function") return value;
const delegated: unknown = value.bind(target);
delegatedMethods.set(property, delegated);
return delegated;
},
});
}
/**
* Race `work` against a `timeoutMs` budget and optional cancellation signal,
* clearing the timer and abort listener as soon as one branch settles.
@@ -118,19 +163,37 @@ const EXTENSION_HANDLER_ABORTED = Symbol("extensionHandlerAborted");
* can `clearTimeout` on the winning branch.
*/
async function raceHandlerWithTimeout<T>(
work: Promise<T>,
work: (handlerSignal: AbortSignal) => Promise<T> | T,
timeoutMs: number,
signal?: AbortSignal,
): Promise<T | typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED> {
if (signal?.aborted) return EXTENSION_HANDLER_ABORTED;
const timeoutController = new AbortController();
const handlerSignal = signal ? AbortSignal.any([signal, timeoutController.signal]) : timeoutController.signal;
const { promise: interruptPromise, resolve: resolveInterrupt } = Promise.withResolvers<
typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED
>();
const timer = setTimeout(() => resolveInterrupt(EXTENSION_HANDLER_TIMEOUT), timeoutMs);
const onAbort = () => resolveInterrupt(EXTENSION_HANDLER_ABORTED);
signal?.addEventListener("abort", onAbort, { once: true });
const timer = setTimeout(() => {
timeoutController.abort(new DOMException(`Handler timed out after ${timeoutMs}ms`, "TimeoutError"));
resolveInterrupt(EXTENSION_HANDLER_TIMEOUT);
}, timeoutMs);
try {
return await Promise.race([work, interruptPromise]);
if (signal?.aborted) return EXTENSION_HANDLER_ABORTED;
const workPromise = Promise.resolve(work(handlerSignal));
const result = await Promise.race([workPromise, interruptPromise]);
if (result === EXTENSION_HANDLER_TIMEOUT) {
await Promise.race([
workPromise.then(
() => undefined,
() => undefined,
),
Bun.sleep(0),
]);
}
return result;
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
@@ -637,7 +700,15 @@ export class ExtensionRunner {
: undefined;
if (signal?.aborted) return undefined;
try {
const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs, signal);
const handlerResult = await raceHandlerWithTimeout(
handlerSignal =>
handler(event, {
...ctx,
ui: createHandlerUIContext(ctx.ui, handlerSignal),
}),
timeoutMs,
signal,
);
if (handlerResult === EXTENSION_HANDLER_ABORTED) return undefined;
if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
const error = `handler timed out after ${timeoutMs}ms`;
@@ -799,7 +870,14 @@ export class ExtensionRunner {
for (const handler of handlers) {
try {
const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs);
const handlerResult = await raceHandlerWithTimeout(
handlerSignal =>
handler(event, {
...ctx,
ui: createHandlerUIContext(ctx.ui, handlerSignal),
}),
timeoutMs,
);
if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
const error = `handler timed out after ${timeoutMs}ms`;
@@ -86,7 +86,7 @@ export class ExtensionUiController {
const uiContext: ExtensionUIContext = {
timeoutStartsOnPresentation: true,
select: (title, options, dialogOptions) => this.showCollabAwareSelector(title, options, dialogOptions),
confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
confirm: (title, message, dialogOptions) => this.showHookConfirm(title, message, dialogOptions),
input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions),
askDialog: (questions, dialogOptions) => this.showAskDialog(questions, dialogOptions),
notify: (message, type) => this.showHookNotify(message, type),
@@ -941,8 +941,8 @@ export class ExtensionUiController {
/**
* Show a confirmation dialog for hooks.
*/
async showHookConfirm(title: string, message: string): Promise<boolean> {
const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"]);
async showHookConfirm(title: string, message: string, dialogOptions?: ExtensionUIDialogOptions): Promise<boolean> {
const result = await this.showHookSelector(`${title}\n${message}`, ["Yes", "No"], dialogOptions);
return result === "Yes";
}
+60 -47
View File
@@ -603,6 +603,57 @@ export function requestRpcEditor(
} as RpcExtensionUIRequest);
return promise;
}
/** Sends an RPC extension dialog and cancels the remote presentation when its signal aborts. */
export function requestRpcDialog<T>(
pendingRequests: Map<string, PendingExtensionRequest>,
output: RpcOutput,
opts: ExtensionUIDialogOptions | undefined,
defaultValue: T,
request: Record<string, unknown>,
parseResponse: (response: RpcExtensionUIResponse) => T,
): Promise<T> {
if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
const id = Snowflake.next() as string;
const { promise, resolve, reject } = Promise.withResolvers<T>();
let timeoutId: NodeJS.Timeout | undefined;
const cleanup = () => {
clearTimeout(timeoutId);
opts?.signal?.removeEventListener("abort", onAbort);
pendingRequests.delete(id);
};
const onAbort = () => {
output({
type: "extension_ui_request",
id: Snowflake.next() as string,
method: "cancel",
targetId: id,
} as RpcExtensionUIRequest);
cleanup();
resolve(defaultValue);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
if (opts?.timeout !== undefined) {
timeoutId = setTimeout(() => {
opts.onTimeout?.();
cleanup();
resolve(defaultValue);
}, opts.timeout);
}
pendingRequests.set(id, {
resolve: response => {
cleanup();
resolve(parseResponse(response));
},
reject,
});
output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest);
return promise;
}
/**
* Run in RPC mode.
* Listens for JSON commands on stdin, outputs events and responses on stdout.
@@ -685,56 +736,14 @@ export async function runRpcMode(
private output: (obj: RpcResponse | RpcExtensionUIRequest | object) => void,
) {}
/** Helper for dialog methods with signal/timeout support */
#createDialogPromise<T>(
opts: ExtensionUIDialogOptions | undefined,
defaultValue: T,
request: Record<string, unknown>,
parseResponse: (response: RpcExtensionUIResponse) => T,
): Promise<T> {
if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
const id = Snowflake.next() as string;
const { promise, resolve, reject } = Promise.withResolvers<T>();
let timeoutId: NodeJS.Timeout | undefined;
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId);
opts?.signal?.removeEventListener("abort", onAbort);
this.pendingRequests.delete(id);
};
const onAbort = () => {
cleanup();
resolve(defaultValue);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });
if (opts?.timeout !== undefined) {
timeoutId = setTimeout(() => {
opts.onTimeout?.();
cleanup();
resolve(defaultValue);
}, opts.timeout);
}
this.pendingRequests.set(id, {
resolve: (response: RpcExtensionUIResponse) => {
cleanup();
resolve(parseResponse(response));
},
reject,
});
this.output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest);
return promise;
}
select(
title: string,
options: ExtensionUISelectItem[],
dialogOptions?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
return this.#createDialogPromise(
return requestRpcDialog(
this.pendingRequests,
this.output,
dialogOptions,
undefined,
{
@@ -748,7 +757,9 @@ export async function runRpcMode(
}
confirm(title: string, message: string, dialogOptions?: ExtensionUIDialogOptions): Promise<boolean> {
return this.#createDialogPromise(
return requestRpcDialog(
this.pendingRequests,
this.output,
dialogOptions,
false,
{ method: "confirm", title, message, timeout: dialogOptions?.timeout },
@@ -768,7 +779,9 @@ export async function runRpcMode(
placeholder?: string,
dialogOptions?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
return this.#createDialogPromise(
return requestRpcDialog(
this.pendingRequests,
this.output,
dialogOptions,
undefined,
{ method: "input", title, placeholder, timeout: dialogOptions?.timeout },
@@ -15,7 +15,11 @@ import {
ExtensionRunner,
testSetExtensionHandlerTimeoutMs,
} from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
import type { ExtensionError, ExtensionServiceTier } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
import type {
ExtensionError,
ExtensionServiceTier,
ExtensionUIContext,
} from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
import { ExtensionToolWrapper } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/wrapper";
import { Type } from "@oh-my-pi/pi-coding-agent/extensibility/typebox";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
@@ -855,6 +859,81 @@ describe("ExtensionRunner", () => {
]);
});
it("observes a session_stop signal aborted synchronously by the handler", async () => {
const extensionPath = path.join(tempDir.path(), "self-cancel-session-stop.ts");
await Bun.write(
extensionPath,
`
export default function(pi) {
pi.on("session_stop", async (_event, ctx) => {
ctx.abort();
await Promise.withResolvers().promise;
});
}
`,
);
const result = await loadTestExtensions([extensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const controller = new AbortController();
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => undefined,
setSessionName: async () => {},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => controller.abort(),
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
);
vi.useFakeTimers();
try {
testSetExtensionHandlerTimeoutMs(100);
const emission = runner.emitSessionStop({
messages: [],
turn_id: 0,
session_id: "session-123",
stop_hook_active: false,
signal: controller.signal,
});
let settled = false;
void emission.then(() => {
settled = true;
});
for (let attempts = 0; attempts < 10 && !settled; attempts++) {
await Promise.resolve();
}
expect(controller.signal.aborted).toBe(true);
expect(settled).toBe(true);
await emission;
} finally {
vi.useRealTimers();
}
});
it("continues to later handlers after empty continuation feedback", async () => {
await Bun.write(
path.join(extensionsDir, "session-stop-empty.ts"),
@@ -1236,6 +1315,95 @@ describe("ExtensionRunner", () => {
warnSpy.mockRestore();
});
it("aborts a tool_call handler's confirmation before returning its timeout block", async () => {
const extensionPath = path.join(tempDir.path(), "confirm-tool-call.ts");
const markerPath = path.join(tempDir.path(), "confirm-settled.txt");
fs.writeFileSync(
extensionPath,
`
import * as fs from "node:fs";
export default function(pi) {
pi.on("tool_call", async (_event, ctx) => {
ctx.ui.notify("Waiting for confirmation");
await ctx.ui.confirm("High-risk command", "Allow this command?");
fs.writeFileSync(${JSON.stringify(markerPath)}, "settled");
});
}
`,
);
const result = await loadTestExtensions([extensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const dialog = Promise.withResolvers<boolean>();
let dialogSignal: AbortSignal | undefined;
const notify = vi.fn<ExtensionUIContext["notify"]>();
const confirm: ExtensionUIContext["confirm"] = async (_title, _message, dialogOptions) => {
dialogSignal = dialogOptions?.signal;
dialogSignal?.addEventListener("abort", () => dialog.resolve(false), { once: true });
return await dialog.promise;
};
const uiPrototype = Object.create(runner.getUIContext(), {
confirm: { value: confirm },
notify: { value: notify },
});
const uiContext: ExtensionUIContext = Object.create(uiPrototype);
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => undefined,
setSessionName: async () => {},
},
{
getModel: () => undefined,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
undefined,
uiContext,
);
testSetExtensionHandlerTimeoutMs(10);
const tool: AgentTool = {
name: "guarded",
label: "Guarded",
description: "must not execute after the extension gate times out",
parameters: Type.Object({}),
strict: true,
execute: async () => ({ content: [{ type: "text", text: "ran" }] }),
};
const wrapped = new ExtensionToolWrapper(tool, runner);
await expect(wrapped.execute("tool-call-id", {})).rejects.toThrow(
`Extension ${extensionPath} timed out after 10ms`,
);
expect(notify).toHaveBeenCalledWith("Waiting for confirmation");
expect(dialogSignal?.aborted).toBe(true);
expect(fs.readFileSync(markerPath, "utf8")).toBe("settled");
});
});
describe("memory context", () => {
@@ -587,4 +587,20 @@ describe("ExtensionUiController dialog serialization", () => {
expect(ctx.hookSelector).toBeUndefined();
expect(editorContainer.children).toEqual([editor]);
});
it("dismisses a confirmation and restores the editor when its signal aborts", async () => {
const { ctx, editor, editorContainer } = createControllerContext();
const controller = new ExtensionUiController(ctx);
const abortController = new AbortController();
const result = controller.showHookConfirm("High-risk command", "Allow this command?", {
signal: abortController.signal,
});
expect(ctx.hookSelector).toBeDefined();
abortController.abort();
expect(await result).toBe(false);
expect(ctx.hookSelector).toBeUndefined();
expect(editorContainer.children).toEqual([editor]);
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from "bun:test";
import { type PendingExtensionRequest, requestRpcDialog } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-mode";
describe("RPC extension UI", () => {
it("cancels the remote dialog when its signal aborts", async () => {
const pendingRequests = new Map<string, PendingExtensionRequest>();
const output = vi.fn<(frame: object) => void>();
const controller = new AbortController();
const result = requestRpcDialog(
pendingRequests,
output,
{ signal: controller.signal },
false,
{ method: "confirm", title: "High-risk command", message: "Allow this command?" },
response => ("confirmed" in response ? response.confirmed : false),
);
const request = output.mock.calls[0]?.[0];
if (!request || !("id" in request) || typeof request.id !== "string") {
throw new Error("Expected the RPC dialog request to carry an id");
}
controller.abort();
expect(await result).toBe(false);
expect(output).toHaveBeenNthCalledWith(1, {
type: "extension_ui_request",
id: request.id,
method: "confirm",
title: "High-risk command",
message: "Allow this command?",
});
expect(output).toHaveBeenNthCalledWith(2, {
type: "extension_ui_request",
id: expect.any(String),
method: "cancel",
targetId: request.id,
});
expect(pendingRequests.size).toBe(0);
});
});