diff --git a/extensions/clickclack/src/inbound.test.ts b/extensions/clickclack/src/inbound.test.ts index 65c89813f6e3..01700bd7ac38 100644 --- a/extensions/clickclack/src/inbound.test.ts +++ b/extensions/clickclack/src/inbound.test.ts @@ -219,6 +219,38 @@ describe("handleClickClackInbound", () => { expect(dispatchReply.mock.calls[0]?.[0].ctxPayload.CommandAuthorized).toBe(true); }); + it("propagates account toolsAllow into agent reply dispatch", async () => { + const runtime = createRuntime(); + setClickClackRuntime(runtime); + const cfg = { + agents: { + defaults: { + model: "openai/gpt-5.4-mini", + }, + }, + tools: { + allow: ["*"], + }, + } satisfies CoreConfig; + + await handleClickClackInbound({ + account: createAgentAccount({ + toolsAllow: ["message"], + }), + config: cfg, + message: createMessage(), + }); + + const dispatchReply = vi.mocked(runtime.channel.inbound.dispatchReply); + expect(dispatchReply).toHaveBeenCalledTimes(1); + const dispatchParams = dispatchReply.mock.calls[0]?.[0] as + | (Record & { + toolsAllow?: unknown; + }) + | undefined; + expect(dispatchParams?.toolsAllow).toEqual(["message"]); + }); + it("accepts ClickClack DM target syntax in allowFrom", async () => { const runtime = createRuntime(); vi.mocked(runtime.channel.commands.shouldComputeCommandAuthorized).mockReturnValue(true); diff --git a/extensions/clickclack/src/inbound.ts b/extensions/clickclack/src/inbound.ts index 672ac1e3f458..2bfe5d3a433d 100644 --- a/extensions/clickclack/src/inbound.ts +++ b/extensions/clickclack/src/inbound.ts @@ -184,6 +184,7 @@ export async function handleClickClackInbound(params: { recordInboundSession: runtime.channel.session.recordInboundSession, dispatchReplyWithBufferedBlockDispatcher: runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher, + toolsAllow: params.account.toolsAllow, delivery: { deliver: async (payload) => { const text = diff --git a/src/auto-reply/dispatch.test.ts b/src/auto-reply/dispatch.test.ts index afec4e6f9165..29fd8b6e5fb7 100644 --- a/src/auto-reply/dispatch.test.ts +++ b/src/auto-reply/dispatch.test.ts @@ -270,6 +270,31 @@ describe("withReplyDispatcher", () => { expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1); }); + it("passes runtime toolsAllow from buffered dispatch into reply resolution", async () => { + hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ + dispatcher: createDispatcher([]), + replyOptions: {}, + markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + }); + hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + }); + + await dispatchInboundMessageWithBufferedDispatcher({ + ctx: buildTestCtx(), + cfg: {} as OpenClawConfig, + toolsAllow: ["message"], + dispatcherOptions: { + deliver: async () => undefined, + }, + }); + + const params = hoisted.dispatchReplyFromConfigMock.mock.calls[0]?.[0]; + expect(params?.replyOptions?.toolsAllow).toEqual(["message"]); + }); + it("runs message_sending hooks before inbound dispatcher delivery", async () => { const runMessageSending = vi.fn(async () => ({ content: "sanitized reply" })); hoisted.getGlobalHookRunnerMock.mockReturnValue({ diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index a375835762ca..b4beaecdbdea 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -52,6 +52,19 @@ type ForegroundReplyFenceSnapshot = { const foregroundReplyFenceByKey = new Map(); const replyPayloadSendingDispatchers = new WeakSet(); +function applyRuntimeToolsAllow( + replyOptions: Omit | undefined, + toolsAllow: string[] | undefined, +): Omit | undefined { + if (toolsAllow === undefined) { + return replyOptions; + } + return { + ...replyOptions, + toolsAllow, + }; +} + function normalizeForegroundReplyFencePart(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -461,9 +474,11 @@ export async function dispatchInboundMessage(params: { ctx: MsgContext | FinalizedMsgContext; cfg: OpenClawConfig; dispatcher: ReplyDispatcher; + toolsAllow?: string[]; replyOptions?: Omit; replyResolver?: GetReplyFromConfig; }): Promise { + const replyOptions = applyRuntimeToolsAllow(params.replyOptions, params.toolsAllow); const finalized = measureDiagnosticsTimelineSpanSync( "auto_reply.finalize_context", () => finalizeInboundContext(params.ctx), @@ -483,7 +498,7 @@ export async function dispatchInboundMessage(params: { }); } installReplyPayloadSendingBeforeDeliver(params.dispatcher, finalized, { - runId: params.replyOptions?.runId, + runId: replyOptions?.runId, }); const result = await withReplyDispatcher({ dispatcher: params.dispatcher, @@ -495,7 +510,7 @@ export async function dispatchInboundMessage(params: { ctx: finalized, cfg: params.cfg, dispatcher: params.dispatcher, - replyOptions: params.replyOptions, + replyOptions, replyResolver: params.replyResolver, }), { @@ -513,6 +528,7 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: { ctx: MsgContext | FinalizedMsgContext; cfg: OpenClawConfig; dispatcherOptions: ReplyDispatcherWithTypingOptions; + toolsAllow?: string[]; replyOptions?: Omit; replyResolver?: GetReplyFromConfig; }): Promise { @@ -575,6 +591,7 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: { ctx: finalized, cfg: params.cfg, dispatcher, + toolsAllow: params.toolsAllow, replyResolver: params.replyResolver, replyOptions: { ...params.replyOptions, @@ -606,6 +623,7 @@ export async function dispatchInboundMessageWithDispatcher(params: { ctx: MsgContext | FinalizedMsgContext; cfg: OpenClawConfig; dispatcherOptions: ReplyDispatcherOptions; + toolsAllow?: string[]; replyOptions?: Omit; replyResolver?: GetReplyFromConfig; }): Promise { @@ -630,6 +648,7 @@ export async function dispatchInboundMessageWithDispatcher(params: { ctx: params.ctx, cfg: params.cfg, dispatcher, + toolsAllow: params.toolsAllow, replyResolver: params.replyResolver, replyOptions: params.replyOptions, }); diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index f393a7a7358e..ec2ba0f7258e 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -91,6 +91,8 @@ export type GetReplyOptions = { shouldSuppressToolErrorWarnings?: () => boolean | undefined; /** If true, run the model without OpenClaw tools for this turn. */ disableTools?: boolean; + /** Runtime tool allow-list for this turn. Empty means no tools. */ + toolsAllow?: string[]; /** If true, include the heartbeat response tool for structured heartbeat outcomes. */ enableHeartbeatTool?: boolean; /** If true, keep the heartbeat response tool available even under narrow tool profiles. */ diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 798fa160c5df..a31a2424cf6e 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -1183,6 +1183,26 @@ describe("runAgentTurnWithFallback", () => { expect(embeddedCall.abortSignal).toBe(replyOperation.abortSignal); }); + it("passes runtime toolsAllow to embedded agent runs", async () => { + state.runEmbeddedAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "ok" }], + meta: {}, + }); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + await runAgentTurnWithFallback( + createMinimalRunAgentTurnParams({ + opts: { + toolsAllow: ["message"], + }, + }), + ); + + expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "embedded run params", { + toolsAllow: ["message"], + }); + }); + it("rechecks queued auto fallback primary probes before running", async () => { const { markAutoFallbackPrimaryProbe } = await import("../../agents/agent-scope.js"); const probe = { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index f9e7a8321564..ee0fddbd3203 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -2212,6 +2212,7 @@ export async function runAgentTurnWithFallback(params: { currentInboundAudio: hasInboundAudio(params.sessionCtx), agentAccountId: params.followupRun.run.agentAccountId, senderIsOwner: params.followupRun.run.senderIsOwner, + toolsAllow: params.opts?.toolsAllow, disableTools: params.opts?.disableTools, abortSignal: runAbortSignal, replyOperation: params.replyOperation, @@ -2329,6 +2330,7 @@ export async function runAgentTurnWithFallback(params: { suppressToolErrorWarnings: params.opts?.shouldSuppressToolErrorWarnings ?? params.opts?.suppressToolErrorWarnings, + toolsAllow: params.opts?.toolsAllow, disableTools: params.opts?.disableTools, enableHeartbeatTool: params.opts?.enableHeartbeatTool, forceHeartbeatTool: params.opts?.forceHeartbeatTool, diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index d6e20ee12348..52fc592fda6e 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -305,6 +305,7 @@ async function runDispatch(params: { suppressUserDelivery?: boolean; suppressReplyLifecycle?: boolean; sourceReplyDeliveryMode?: "automatic" | "message_tool_only"; + toolsAllow?: string[]; }) { const targetSessionKey = params.sessionKeyOverride ?? sessionKey; return tryDispatchAcpReply({ @@ -332,6 +333,7 @@ async function runDispatch(params: { : {}), shouldSendToolSummaries: true, bypassForCommand: false, + toolsAllow: params.toolsAllow, ...(params.onReplyStart ? { onReplyStart: params.onReplyStart } : {}), recordProcessed: vi.fn(), markIdle: vi.fn(), @@ -1388,6 +1390,35 @@ describe("tryDispatchAcpReply", () => { expect(bindingServiceMocks.unbind).not.toHaveBeenCalled(); }); + it("fails closed when ACP dispatch cannot enforce restrictive runtime toolsAllow", async () => { + setReadyAcpResolution(); + const { dispatcher } = createDispatcher(); + + await runDispatch({ + bodyForAgent: "test", + dispatcher, + toolsAllow: ["message"], + }); + + expect(managerMocks.runTurn).not.toHaveBeenCalled(); + expect(dispatcherCall(dispatcher.sendFinalReply).isError).toBe(true); + expect(dispatcherCall(dispatcher.sendFinalReply).text).toContain("runtime toolsAllow"); + }); + + it("allows wildcard runtime toolsAllow through ACP dispatch", async () => { + setReadyAcpResolution(); + const { dispatcher } = createDispatcher(); + + await runDispatch({ + bodyForAgent: "test", + dispatcher, + toolsAllow: ["*"], + }); + + expect(managerMocks.runTurn).toHaveBeenCalledOnce(); + expect(runTurnCall().text).toBe("test"); + }); + it("does not unbind stale bindings when ACP dispatch is disabled by policy", async () => { managerMocks.resolveSession.mockReturnValue({ kind: "stale", diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index 7f9657f38db3..da688044fa1d 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -11,7 +11,7 @@ import { normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../../acp/policy.js"; -import { type AcpRuntimeError, toAcpRuntimeError } from "../../acp/runtime/errors.js"; +import { AcpRuntimeError, toAcpRuntimeError } from "../../acp/runtime/errors.js"; import { resolveAgentDir, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { TtsAutoMode } from "../../config/types.tts.js"; @@ -122,6 +122,13 @@ function resolveAcpTurnText(params: { return params.promptText ? `${guidance}\n\n${params.promptText}` : guidance; } +function isRestrictiveRuntimeToolsAllow(toolsAllow: string[] | undefined): boolean { + if (toolsAllow === undefined) { + return false; + } + return !toolsAllow.some((entry) => normalizeLowercaseStringOrEmpty(entry) === "*"); +} + async function hasBoundConversationForSession(params: { cfg: OpenClawConfig; sessionKey: string; @@ -361,6 +368,7 @@ export async function tryDispatchAcpReply(params: { dispatcher: ReplyDispatcher; runId?: string; sessionKey?: string; + toolsAllow?: string[]; images?: Array<{ data: string; mimeType: string }>; abortSignal?: AbortSignal; inboundAudio: boolean; @@ -505,6 +513,12 @@ export async function tryDispatchAcpReply(params: { if (dispatchPolicyError) { throw dispatchPolicyError; } + if (isRestrictiveRuntimeToolsAllow(params.toolsAllow)) { + throw new AcpRuntimeError( + "ACP_DISPATCH_DISABLED", + "ACP dispatch cannot enforce runtime toolsAllow for this session; use an embedded runtime for restricted tool policy.", + ); + } if (acpResolution.kind === "stale") { await maybeUnbindStaleBoundConversations({ targetSessionKey: canonicalSessionKey, diff --git a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts index 46e20d9e01b6..b843c34df364 100644 --- a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts @@ -35,6 +35,7 @@ function firstReplyDispatchCall() { | [ { sessionKey?: string; + toolsAllow?: string[]; sendPolicy?: string; inboundAudio?: boolean; }, @@ -128,6 +129,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { dispatcher: createDispatcher(), fastAbortResolver: async () => ({ handled: false, aborted: false }), formatAbortReplyTextResolver: () => "⚙️ Agent was aborted.", + replyOptions: { toolsAllow: ["message"] }, replyResolver: async () => ({ text: "model reply" }), }); @@ -140,6 +142,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { expect(hookMocks.runner.runReplyDispatch).toHaveBeenCalledOnce(); const [replyDispatchEvent, replyDispatchRuntime] = firstReplyDispatchCall() ?? []; expect(replyDispatchEvent?.sessionKey).toBe("agent:test:session"); + expect(replyDispatchEvent?.toolsAllow).toEqual(["message"]); expect(replyDispatchEvent?.sendPolicy).toBe("allow"); expect(replyDispatchEvent?.inboundAudio).toBe(false); expect(replyDispatchRuntime?.cfg).toBe(emptyConfig); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index cb39377b8419..5fd440747ae6 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -2138,6 +2138,7 @@ export async function dispatchReplyFromConfig( ctx, runId: params.replyOptions?.runId, sessionKey: acpDispatchSessionKey, + toolsAllow: params.replyOptions?.toolsAllow, images: params.replyOptions?.images, inboundAudio, sessionTtsAuto, @@ -2777,6 +2778,7 @@ export async function dispatchReplyFromConfig( ctx, runId: params.replyOptions?.runId, sessionKey: acpDispatchSessionKey, + toolsAllow: params.replyOptions?.toolsAllow, images: params.replyOptions?.images, inboundAudio, sessionTtsAuto, diff --git a/src/auto-reply/reply/provider-dispatcher.test.ts b/src/auto-reply/reply/provider-dispatcher.test.ts new file mode 100644 index 000000000000..db8b8e429980 --- /dev/null +++ b/src/auto-reply/reply/provider-dispatcher.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { + ReplyDispatcherOptions, + ReplyDispatcherWithTypingOptions, +} from "./reply-dispatcher.js"; + +type BufferedDispatchFn = + typeof import("../dispatch.js").dispatchInboundMessageWithBufferedDispatcher; +type PlainDispatchFn = typeof import("../dispatch.js").dispatchInboundMessageWithDispatcher; + +const hoisted = vi.hoisted(() => ({ + bufferedDispatchMock: vi.fn(), + plainDispatchMock: vi.fn(), +})); + +vi.mock("../dispatch.js", () => ({ + dispatchInboundMessageWithBufferedDispatcher: (...args: Parameters) => + hoisted.bufferedDispatchMock(...args), + dispatchInboundMessageWithDispatcher: (...args: Parameters) => + hoisted.plainDispatchMock(...args), +})); + +const { dispatchReplyWithBufferedBlockDispatcher, dispatchReplyWithDispatcher } = + await import("./provider-dispatcher.js"); + +const dispatchResult = { + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, +}; + +describe("provider dispatcher wrappers", () => { + beforeEach(() => { + vi.clearAllMocks(); + hoisted.bufferedDispatchMock.mockResolvedValue(dispatchResult); + hoisted.plainDispatchMock.mockResolvedValue(dispatchResult); + }); + + it("forwards runtime toolsAllow through the buffered wrapper", async () => { + const dispatcherOptions = { + deliver: async () => ({ visibleReplySent: false }), + } satisfies ReplyDispatcherWithTypingOptions; + + await dispatchReplyWithBufferedBlockDispatcher({ + ctx: { Body: "hello" }, + cfg: {} as OpenClawConfig, + dispatcherOptions, + toolsAllow: ["message"], + }); + + expect(hoisted.bufferedDispatchMock).toHaveBeenCalledTimes(1); + expect(hoisted.bufferedDispatchMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + dispatcherOptions, + toolsAllow: ["message"], + }), + ); + }); + + it("forwards runtime toolsAllow through the plain wrapper", async () => { + const dispatcherOptions = { + deliver: async () => ({ visibleReplySent: false }), + } satisfies ReplyDispatcherOptions; + + await dispatchReplyWithDispatcher({ + ctx: { Body: "hello" }, + cfg: {} as OpenClawConfig, + dispatcherOptions, + toolsAllow: ["message"], + }); + + expect(hoisted.plainDispatchMock).toHaveBeenCalledTimes(1); + expect(hoisted.plainDispatchMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + dispatcherOptions, + toolsAllow: ["message"], + }), + ); + }); +}); diff --git a/src/auto-reply/reply/provider-dispatcher.ts b/src/auto-reply/reply/provider-dispatcher.ts index cc735b159d4c..65c5f56cfdcb 100644 --- a/src/auto-reply/reply/provider-dispatcher.ts +++ b/src/auto-reply/reply/provider-dispatcher.ts @@ -20,6 +20,7 @@ export const dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBuffered ctx: params.ctx, cfg: params.cfg, dispatcherOptions: params.dispatcherOptions, + toolsAllow: params.toolsAllow, replyResolver: params.replyResolver, replyOptions: params.replyOptions, }); @@ -31,6 +32,7 @@ export const dispatchReplyWithDispatcher: DispatchReplyWithDispatcher = async (p ctx: params.ctx, cfg: params.cfg, dispatcherOptions: params.dispatcherOptions, + toolsAllow: params.toolsAllow, replyResolver: params.replyResolver, replyOptions: params.replyOptions, }); diff --git a/src/auto-reply/reply/provider-dispatcher.types.ts b/src/auto-reply/reply/provider-dispatcher.types.ts index 538ab93192b0..eb2e7387af32 100644 --- a/src/auto-reply/reply/provider-dispatcher.types.ts +++ b/src/auto-reply/reply/provider-dispatcher.types.ts @@ -17,6 +17,7 @@ export type DispatchReplyWithBufferedBlockDispatcher = (params: { ctx: DispatchReplyContext; cfg: OpenClawConfig; dispatcherOptions: ReplyDispatcherWithTypingOptions; + toolsAllow?: string[]; replyOptions?: DispatchReplyOptions; replyResolver?: GetReplyFromConfig; }) => Promise; @@ -26,6 +27,7 @@ export type DispatchReplyWithDispatcher = (params: { ctx: DispatchReplyContext; cfg: OpenClawConfig; dispatcherOptions: ReplyDispatcherOptions; + toolsAllow?: string[]; replyOptions?: DispatchReplyOptions; replyResolver?: GetReplyFromConfig; }) => Promise; diff --git a/src/channels/turn/kernel.ts b/src/channels/turn/kernel.ts index a330d5837e57..303228e65061 100644 --- a/src/channels/turn/kernel.ts +++ b/src/channels/turn/kernel.ts @@ -425,6 +425,7 @@ export async function dispatchAssembledChannelTurn( }, onError: params.delivery.onError, }, + toolsAllow: params.toolsAllow, replyOptions: replyPipeline.replyOptions, replyResolver: params.replyResolver, }), diff --git a/src/channels/turn/types.ts b/src/channels/turn/types.ts index beb095e2094d..469acfd939fb 100644 --- a/src/channels/turn/types.ts +++ b/src/channels/turn/types.ts @@ -381,6 +381,7 @@ export type AssembledChannelTurn = { delivery: ChannelEventDeliveryAdapter; replyPipeline?: ChannelTurnReplyPipelineOptions; dispatcherOptions?: ChannelTurnDispatcherOptions; + toolsAllow?: string[]; replyOptions?: Omit; replyResolver?: GetReplyFromConfig; record?: ChannelTurnRecordOptions; diff --git a/src/plugin-sdk/acp-runtime-backend.ts b/src/plugin-sdk/acp-runtime-backend.ts index 91ae2dca5b89..f70ce5b976df 100644 --- a/src/plugin-sdk/acp-runtime-backend.ts +++ b/src/plugin-sdk/acp-runtime-backend.ts @@ -79,6 +79,7 @@ export async function tryDispatchAcpReplyHook( dispatcher: ctx.dispatcher, runId: event.runId, sessionKey: event.sessionKey, + toolsAllow: event.toolsAllow, images: event.images, abortSignal: ctx.abortSignal, inboundAudio: event.inboundAudio, diff --git a/src/plugin-sdk/acp-runtime.test.ts b/src/plugin-sdk/acp-runtime.test.ts index fc36b8d7e350..f0a0df76d519 100644 --- a/src/plugin-sdk/acp-runtime.test.ts +++ b/src/plugin-sdk/acp-runtime.test.ts @@ -198,6 +198,20 @@ describe("tryDispatchAcpReplyHook", () => { expect(livePredicate?.()).toBe(false); }); + it("passes runtime toolsAllow through to ACP dispatch", async () => { + bypassMock.mockResolvedValue(false); + dispatchMock.mockResolvedValue({ + queuedFinal: false, + counts: { tool: 0, block: 0, final: 0 }, + }); + + await tryDispatchAcpReplyHook({ ...event, toolsAllow: ["message"] }, ctx); + + expect(dispatchMock).toHaveBeenCalledOnce(); + const [payload] = dispatchMock.mock.calls[0] ?? []; + expect((payload as { toolsAllow?: string[] }).toolsAllow).toStrictEqual(["message"]); + }); + it("returns unhandled when ACP dispatcher declines the turn", async () => { bypassMock.mockResolvedValue(false); dispatchMock.mockResolvedValue(undefined); diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index c684cc532abe..e424a160f086 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -440,6 +440,7 @@ export type PluginHookReplyDispatchEvent = { ctx: FinalizedMsgContext; runId?: string; sessionKey?: string; + toolsAllow?: string[]; images?: Array<{ data: string; mimeType: string }>; inboundAudio: boolean; sessionTtsAuto?: TtsAutoMode;