diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7fc96cb5e..28b776499 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -27,6 +27,9 @@ - Added `--json` output plus `-e/--extension`, `--no-extensions`, and `--config` controls to `omp models` listings - Added `skills.enableAgentsUser` and `skills.enableAgentsProject` settings (default on) so the canonical OMP-native `~/.agent[s]/skills` and project-walkup `.agent[s]/skills` are configurable independently from the third-party Claude/Codex/Pi toggles. + +- Added extension lifecycle events for tool approval prompts: `tool_approval_requested` before the approval wait and `tool_approval_resolved` after approve, deny, or approval prompt failure. + ### Changed - Model registry merge and `omp models` / model picker handle unknown context/output limits (`null`) — unknown limits render as `-` instead of a fake `222K`/`8.9K`. diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 0948a7198..53882e41f 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -56,6 +56,7 @@ import type { SearchToolInput, WriteToolInput, } from "../../tools"; +import type { ApprovalMode } from "../../tools/approval"; import type { EventBus } from "../../utils/event-bus"; import type { AgentEndEvent, @@ -608,6 +609,24 @@ export interface InputEvent { // Tool Events // ============================================================================ +export interface ToolApprovalRequestedEvent { + type: "tool_approval_requested"; + sessionId: string; + toolCallId: string; + toolName: string; + reason?: string; + approvalMode: ApprovalMode; +} + +export interface ToolApprovalResolvedEvent { + type: "tool_approval_resolved"; + sessionId: string; + toolCallId: string; + toolName: string; + approved: boolean; + reason?: string; +} + interface ToolCallEventBase { type: "tool_call"; toolCallId: string; @@ -775,7 +794,9 @@ export type ExtensionEvent = | UserPythonEvent | InputEvent | ToolCallEvent - | ToolResultEvent; + | ToolResultEvent + | ToolApprovalRequestedEvent + | ToolApprovalResolvedEvent; // ============================================================================ // Event Results @@ -946,6 +967,8 @@ export interface ExtensionAPI { on(event: "goal_updated", handler: ExtensionHandler): void; on(event: "credential_disabled", handler: ExtensionHandler): void; on(event: "input", handler: ExtensionHandler): void; + on(event: "tool_approval_requested", handler: ExtensionHandler): void; + on(event: "tool_approval_resolved", handler: ExtensionHandler): void; on(event: "tool_call", handler: ExtensionHandler): void; on(event: "tool_result", handler: ExtensionHandler): void; on(event: "user_bash", handler: ExtensionHandler): void; diff --git a/packages/coding-agent/src/extensibility/extensions/wrapper.ts b/packages/coding-agent/src/extensibility/extensions/wrapper.ts index 8d9f8374a..9043c0703 100644 --- a/packages/coding-agent/src/extensibility/extensions/wrapper.ts +++ b/packages/coding-agent/src/extensibility/extensions/wrapper.ts @@ -121,8 +121,36 @@ export class ExtensionToolWrapper { + if (!hasApprovalHandlers) return; + await this.runner.emit({ + type: "tool_approval_resolved", + sessionId, + toolName: this.tool.name, + toolCallId, + approved, + ...(reason ? { reason } : {}), + }); + }; + // Check if UI is available if (!this.runner.hasUI()) { + const reason = "no interactive UI available"; + await resolveApproval(false, reason); throw new Error( `Tool "${this.tool.name}" requires approval but no interactive UI available.\n` + `Options:\n` + @@ -133,11 +161,19 @@ export class ExtensionToolWrapper { }); }); + describe("tool approval lifecycle", () => { + const initializeRunner = ( + runner: ExtensionRunner, + select: (title: string, options: string[]) => Promise, + ) => { + 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, + { + select, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + onTerminalInput: () => () => {}, + setStatus: () => {}, + setWorkingMessage: () => {}, + setWidget: () => {}, + setFooter: () => {}, + setHeader: () => {}, + setTitle: () => {}, + custom: async () => undefined as T, + pasteToEditor: () => {}, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + setEditorComponent: () => {}, + get theme() { + return {} as never; + }, + getAllThemes: async () => [], + getTheme: async () => undefined, + setTheme: async () => ({ success: false, error: "not implemented" }), + getToolsExpanded: () => false, + setToolsExpanded: () => {}, + }, + ); + }; + + const approvalTool = { + name: "dangerous_tool", + label: "Dangerous Tool", + description: "Test tool", + parameters: {} as never, + approval: "exec" as const, + execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), + }; + + it("emits requested before waiting and resolved after approval", async () => { + const events: Array<{ type: string; approved?: boolean }> = []; + const extCode = ` + export default function(pi) { + pi.on("tool_approval_requested", async (event) => { + globalThis.__approvalEvents.push({ type: event.type }); + }); + pi.on("tool_approval_resolved", async (event) => { + globalThis.__approvalEvents.push({ type: event.type, approved: event.approved }); + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "approval-events.ts"), extCode); + const globalState = globalThis as typeof globalThis & { __approvalEvents?: typeof events }; + globalState.__approvalEvents = events; + + const result = await loadTestExtensions(); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + const select = vi.fn(async () => { + events.push({ type: "ui_select" }); + return "Approve"; + }); + initializeRunner(runner, select); + + const wrapper = new ExtensionToolWrapper(approvalTool, runner); + await (wrapper as ExtensionToolWrapper).execute("call-approval", {}, undefined, undefined, { + sessionManager, + modelRegistry, + model: undefined, + isIdle: () => true, + hasQueuedMessages: () => false, + abort: () => {}, + settings: { get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}) } as never, + }); + + expect(events).toEqual([ + { type: "tool_approval_requested" }, + { type: "ui_select" }, + { type: "tool_approval_resolved", approved: true }, + ]); + expect(select).toHaveBeenCalledWith(expect.stringContaining("Allow tool: dangerous_tool"), [ + "Approve", + "Deny", + ]); + delete globalState.__approvalEvents; + }); + + it("emits resolved false when approval is denied", async () => { + const events: Array<{ type: string; approved?: boolean; reason?: string }> = []; + const extCode = ` + export default function(pi) { + pi.on("tool_approval_requested", async (event) => { + globalThis.__deniedApprovalEvents.push({ type: event.type, reason: event.reason }); + }); + pi.on("tool_approval_resolved", async (event) => { + globalThis.__deniedApprovalEvents.push({ + type: event.type, + approved: event.approved, + reason: event.reason, + }); + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "denied-approval-events.ts"), extCode); + const globalState = globalThis as typeof globalThis & { __deniedApprovalEvents?: typeof events }; + globalState.__deniedApprovalEvents = events; + + const result = await loadTestExtensions(); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + initializeRunner(runner, async () => "Deny"); + + const wrapper = new ExtensionToolWrapper(approvalTool, runner); + await expect( + (wrapper as ExtensionToolWrapper).execute("call-denied", {}, undefined, undefined, { + sessionManager, + modelRegistry, + model: undefined, + isIdle: () => true, + hasQueuedMessages: () => false, + abort: () => {}, + settings: { get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}) } as never, + }), + ).rejects.toThrow("Tool call denied by user: dangerous_tool"); + + expect(events).toEqual([ + { type: "tool_approval_requested", reason: undefined }, + { type: "tool_approval_resolved", approved: false, reason: "denied by user" }, + ]); + delete globalState.__deniedApprovalEvents; + }); + it("emits resolved false when the approval prompt throws", async () => { + const events: Array<{ type: string; approved?: boolean; reason?: string }> = []; + const extCode = ` + export default function(pi) { + pi.on("tool_approval_requested", async (event) => { + globalThis.__thrownApprovalEvents.push({ type: event.type, reason: event.reason }); + }); + pi.on("tool_approval_resolved", async (event) => { + globalThis.__thrownApprovalEvents.push({ + type: event.type, + approved: event.approved, + reason: event.reason, + }); + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "thrown-approval-events.ts"), extCode); + const globalState = globalThis as typeof globalThis & { __thrownApprovalEvents?: typeof events }; + globalState.__thrownApprovalEvents = events; + + const result = await loadTestExtensions(); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + initializeRunner(runner, async () => { + throw new Error("dialog aborted"); + }); + + const wrapper = new ExtensionToolWrapper(approvalTool, runner); + await expect( + (wrapper as ExtensionToolWrapper).execute("call-thrown", {}, undefined, undefined, { + sessionManager, + modelRegistry, + model: undefined, + isIdle: () => true, + hasQueuedMessages: () => false, + abort: () => {}, + settings: { get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}) } as never, + }), + ).rejects.toThrow("dialog aborted"); + + expect(events).toEqual([ + { type: "tool_approval_requested", reason: undefined }, + { type: "tool_approval_resolved", approved: false, reason: "dialog aborted" }, + ]); + delete globalState.__thrownApprovalEvents; + }); + it("emits lifecycle events when partial context has no session manager", async () => { + const events: Array<{ type: string; approved?: boolean; reason?: string; sessionId?: string }> = []; + const extCode = ` + export default function(pi) { + pi.on("tool_approval_requested", async (event) => { + globalThis.__partialContextApprovalEvents.push({ + type: event.type, + sessionId: event.sessionId, + reason: event.reason, + }); + }); + pi.on("tool_approval_resolved", async (event) => { + globalThis.__partialContextApprovalEvents.push({ + type: event.type, + sessionId: event.sessionId, + approved: event.approved, + reason: event.reason, + }); + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "partial-context-approval-events.ts"), extCode); + const globalState = globalThis as typeof globalThis & { __partialContextApprovalEvents?: typeof events }; + globalState.__partialContextApprovalEvents = events; + + const result = await loadTestExtensions(); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + + const wrapper = new ExtensionToolWrapper(approvalTool, runner); + await expect( + (wrapper as ExtensionToolWrapper).execute("call-partial-context", {}, undefined, undefined, { + settings: { get: (key: string) => (key === "tools.approvalMode" ? "always-ask" : {}) }, + } as never), + ).rejects.toThrow('Tool "dangerous_tool" requires approval but no interactive UI available.'); + + expect(events).toEqual([ + { type: "tool_approval_requested", sessionId: "", reason: undefined }, + { + type: "tool_approval_resolved", + sessionId: "", + approved: false, + reason: "no interactive UI available", + }, + ]); + delete globalState.__partialContextApprovalEvents; + }); + }); + describe("hasHandlers", () => { it("returns true when handlers exist for event type", async () => { const extCode = `