import type { Tool as SdkTool, ToolInvocation, ToolResultObject } from "@github/copilot-sdk"; import type { AnyAgentTool, SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createCopilotToolBridge, convertOpenClawToolToSdkTool, supportsModelTools, } from "./tool-bridge.js"; type FakeTool = AnyAgentTool & { execute: ReturnType; prepareArguments?: ReturnType; }; function createDeferred() { let rejectPromise: ((reason?: unknown) => void) | undefined; let resolvePromise: ((value: T | PromiseLike) => void) | undefined; const promise = new Promise((resolve, reject) => { resolvePromise = resolve; rejectPromise = reject; }); return { promise, reject(reason?: unknown) { rejectPromise?.(reason); }, resolve(value: T) { resolvePromise?.(value); }, }; } function flushAsync() { return Promise.resolve().then(() => {}); } function makeInvocation(overrides: Partial = {}): ToolInvocation { return { arguments: { value: "input" }, sessionId: "session-1", toolCallId: "call-1", toolName: "tool-a", ...overrides, }; } function makeTool( overrides: Partial = {}, result: { content?: unknown; details: unknown } = { content: [{ text: "done", type: "text" }], details: null, }, ): FakeTool { return { description: "A fake tool", execute: vi.fn(async () => result), label: "Fake Tool", name: "tool-a", parameters: { properties: { value: { type: "string" } }, type: "object", } as never, ...overrides, } as unknown as FakeTool; } function getError(result: ToolResultObject): string | undefined { return result.error; } function runSdkTool(tool: SdkTool, args: unknown, invocation = makeInvocation()) { if (!tool.handler) { throw new Error(`SDK tool '${tool.name}' has no handler`); } return tool.handler(args, invocation); } afterEach(() => { vi.restoreAllMocks(); }); describe("supportsModelTools", () => { it("returns true for github-copilot and false otherwise", () => { expect(supportsModelTools("github-copilot")).toBe(true); expect(supportsModelTools("openai")).toBe(false); expect(supportsModelTools("github")).toBe(false); expect(supportsModelTools("openclaw")).toBe(false); expect(supportsModelTools("copilot")).toBe(false); expect(supportsModelTools("")).toBe(false); }); }); describe("createCopilotToolBridge", () => { it("returns empty arrays for unsupported providers without calling the seam", async () => { const createOpenClawCodingTools = vi.fn(async () => [makeTool()]); const result = await createCopilotToolBridge({ agentId: "agent-1", createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "openai", sessionId: "session-1", }); expect(result).toEqual({ sdkTools: [], sourceTools: [] }); expect(createOpenClawCodingTools).toHaveBeenCalledTimes(0); }); it("forwards supported fields to injected createOpenClawCodingTools", async () => { const controller = new AbortController(); const createOpenClawCodingTools = vi.fn(async () => [makeTool()]); await createCopilotToolBridge({ abortSignal: controller.signal, agentDir: "/agent", agentId: "agent-1", createOpenClawCodingTools, cwd: "/workspace/task", modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", sessionKey: "session-key", workspaceDir: "/workspace", }); expect(createOpenClawCodingTools).toHaveBeenCalledTimes(1); // F6: the bridge now forwards PI-parity context fields too. This // test continues to assert the core flat fields plumb through; full // PI-parity is asserted in dedicated tests below. expect(createOpenClawCodingTools).toHaveBeenCalledWith( expect.objectContaining({ abortSignal: controller.signal, agentDir: "/agent", agentId: "agent-1", cwd: "/workspace/task", modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", // sessionKey is the sandboxSessionKey derivation; with no // attemptParams the bridge falls back to input.sessionKey. sessionKey: "session-key", workspaceDir: "/workspace", }), ); }); it("returns sdkTools and sourceTools with matching lengths", async () => { const sourceTools = [makeTool(), makeTool({ name: "tool-b" })]; const result = await createCopilotToolBridge({ agentId: "agent-1", createOpenClawCodingTools: async () => sourceTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools).toBe(sourceTools); expect(result.sdkTools).toHaveLength(2); expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool-a", "tool-b"]); }); it("throws when createOpenClawCodingTools returns a non-array", async () => { await expect( createCopilotToolBridge({ agentId: "agent-1", createOpenClawCodingTools: async () => ({ tools: [] }) as never, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }), ).rejects.toThrow("createOpenClawCodingTools must return an array"); }); it("throws when createOpenClawCodingTools rejects and includes the cause", async () => { await expect( createCopilotToolBridge({ agentId: "agent-1", createOpenClawCodingTools: async () => { throw new Error("factory failed"); }, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }), ).rejects.toThrow("factory failed"); }); it("throws on duplicate tool names and lists all duplicates", async () => { await expect( createCopilotToolBridge({ agentId: "agent-1", createOpenClawCodingTools: async () => [ makeTool({ name: "alpha" }), makeTool({ name: "beta" }), makeTool({ name: "alpha" }), makeTool({ name: "beta" }), ], modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }), ).rejects.toThrow("duplicate tool names: alpha, beta"); }); // F6: PI-parity tool context. The bridged OpenClaw tools register // with the SDK as `overridesBuiltInTool: true, skipPermission: true`, // so the wrapped-tool enforcement layer // (src/agents/pi-tools.before-tool-call.ts) is the single gate for // permission, owner-only allowlists, loop detection, trusted-plugin // policies, and two-phase plugin approvals. Missing context fields // silently degrade those policy decisions. See round-3 maintainer // finding F6 and docs/plugins/copilot.md. describe("PI-parity attempt context (F6)", () => { function captureCall() { const createOpenClawCodingTools = vi.fn(async () => [makeTool()]); return { createOpenClawCodingTools, getOpts: () => (createOpenClawCodingTools.mock.calls[0] as unknown[] | undefined)?.[0] as Record< string, unknown >, }; } it("forwards identity, owner/policy, and channel/routing fields from attemptParams", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { agentAccountId: "acct-1", senderId: "sender-1", senderName: "Ada", senderUsername: "ada", senderE164: "+15551234567", senderIsOwner: true, memberRoleIds: ["role-admin"], allowGatewaySubagentBinding: true, spawnedBy: "parent:agent", groupId: "g-1", groupChannel: "#general", groupSpace: "team-1", currentChannelId: "C123", currentThreadTs: "1700000000.000100", currentMessageId: "M-1", messageProvider: "slack", messageTo: "U-1", messageThreadId: "1700000000.000100", replyToMode: "first", requireExplicitMessageTarget: true, disableMessageTool: false, forceMessageTool: true, enableHeartbeatTool: true, forceHeartbeatTool: false, } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); const opts = getOpts(); expect(opts).toMatchObject({ agentAccountId: "acct-1", senderId: "sender-1", senderName: "Ada", senderUsername: "ada", senderE164: "+15551234567", senderIsOwner: true, memberRoleIds: ["role-admin"], allowGatewaySubagentBinding: true, spawnedBy: "parent:agent", groupId: "g-1", groupChannel: "#general", groupSpace: "team-1", currentChannelId: "C123", currentThreadTs: "1700000000.000100", currentMessageId: "M-1", messageProvider: "slack", messageTo: "U-1", messageThreadId: "1700000000.000100", replyToMode: "first", requireExplicitMessageTarget: true, forceMessageTool: true, enableHeartbeatTool: true, }); }); it("falls back messageProvider to attemptParams.messageChannel when messageProvider is absent (codex parity)", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { messageChannel: "telegram" } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(getOpts().messageProvider).toBe("telegram"); }); it("forwards authProfileStore, runId, config, and run hooks (onToolOutcome) from attemptParams", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); const authProfileStore = { kind: "fake-store" } as never; const config = { agents: {} } as never; const onToolOutcome = vi.fn(); await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { authProfileStore, runId: "run-1", config, onToolOutcome, } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); const opts = getOpts(); expect(opts.authProfileStore).toBe(authProfileStore); expect(opts.runId).toBe("run-1"); expect(opts.config).toBe(config); expect(opts.onToolOutcome).toBe(onToolOutcome); }); it("prefers the unscoped toolAuthProfileStore when building OpenClaw tools", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); const authProfileStore = { kind: "transport-scoped-store" } as never; const toolAuthProfileStore = { kind: "tool-store" } as never; await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { authProfileStore, toolAuthProfileStore, } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(getOpts().authProfileStore).toBe(toolAuthProfileStore); }); it("derives sandboxSessionKey and runSessionKey from attemptParams (PI parity)", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); await createCopilotToolBridge({ agentId: "agent-1", // Mirrors PI attempt.ts:1053-1060: when sandboxSessionKey // differs from sessionKey, sessionKey is published as the // sandbox key and the real run key is exposed as runSessionKey // so `session_status: "current"` resolves to the live session. attemptParams: { sandboxSessionKey: "sandbox:agent:main", sessionKey: "agent:main:main", } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); const opts = getOpts(); expect(opts.sessionKey).toBe("sandbox:agent:main"); expect(opts.runSessionKey).toBe("agent:main:main"); }); it("derives runSessionKey as undefined when sandboxSessionKey equals sessionKey", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { sessionKey: "agent:main:main" } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); const opts = getOpts(); expect(opts.sessionKey).toBe("agent:main:main"); expect(opts.runSessionKey).toBeUndefined(); }); it("falls back sessionKey to input.sessionKey when attemptParams omits it (legacy callers)", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); await createCopilotToolBridge({ agentId: "agent-1", attemptParams: {}, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", sessionKey: "fallback-key", }); expect(getOpts().sessionKey).toBe("fallback-key"); }); it("computes modelApi, modelContextWindowTokens, modelCompat, and modelHasVision from attemptParams.model", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { model: { api: "openai-responses", contextWindow: 200_000, input: ["text", "image"], compat: { some: "shape" }, }, } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); const opts = getOpts(); expect(opts.modelApi).toBe("openai-responses"); expect(opts.modelContextWindowTokens).toBe(200_000); expect(opts.modelHasVision).toBe(true); expect(opts.modelCompat).toEqual({ some: "shape" }); }); it("modelHasVision is false when model.input does not include 'image'", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { model: { input: ["text"] } } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(getOpts().modelHasVision).toBe(false); }); it("spreads execOverrides and bashElevated into the exec field (PI parity)", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); const execOverrides = { security: "fast" } as never; const bashElevated = { allowed: true } as never; await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { execOverrides, bashElevated } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); const exec = getOpts().exec as Record; expect(exec).toMatchObject({ security: "fast", elevated: { allowed: true } }); }); it("forwards run-trace context (trigger, jobId, memoryFlushWritePath, toolsAllow) via buildEmbeddedAttemptToolRunContext", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { trigger: "cron", jobId: "job-1", memoryFlushWritePath: ".memory/append.md", toolsAllow: ["read", "edit"], } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); const opts = getOpts(); expect(opts.trigger).toBe("cron"); expect(opts.jobId).toBe("job-1"); expect(opts.memoryFlushWritePath).toBe(".memory/append.md"); // buildEmbeddedAttemptToolRunContext renames toolsAllow -> // runtimeToolAllowlist; consumers (PI plugin tools) read the // renamed key, so the bridge must surface the renamed shape too. expect(opts.runtimeToolAllowlist).toEqual(["read", "edit"]); }); it("onYield routes to sessionRef.current.abort() and invokes onYieldDetected when the live session is bound", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); const abort = vi.fn(); const sessionRef: { current: { abort?: () => unknown } | undefined } = { current: undefined, }; const onYieldDetected = vi.fn(); await createCopilotToolBridge({ agentId: "agent-1", createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", onYieldDetected, sessionId: "session-1", sessionRef, }); const onYield = getOpts().onYield as (msg?: string) => void; // No session bound yet: onYield must no-op the abort path // without throwing, but the onYieldDetected notification fires // regardless so a yield before session-bind is still surfaced // to the final attempt result. expect(() => onYield("early yield")).not.toThrow(); expect(abort).toHaveBeenCalledTimes(0); expect(onYieldDetected).toHaveBeenCalledTimes(1); expect(onYieldDetected).toHaveBeenCalledWith("early yield"); // Bind the session after the fact (attempt.ts does this after // createSession/resumeSession resolves) and verify subsequent // yields abort it and continue to notify. sessionRef.current = { abort }; onYield("now yield"); expect(abort).toHaveBeenCalledTimes(1); expect(onYieldDetected).toHaveBeenCalledTimes(2); expect(onYieldDetected).toHaveBeenLastCalledWith("now yield"); }); it("onYield still aborts the live session when onYieldDetected throws (defense in depth)", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); const abort = vi.fn(); const sessionRef: { current: { abort?: () => unknown } | undefined } = { current: { abort }, }; const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); await createCopilotToolBridge({ agentId: "agent-1", createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", onYieldDetected: () => { throw new Error("handler boom"); }, sessionId: "session-1", sessionRef, }); const onYield = getOpts().onYield as (msg?: string) => void; expect(() => onYield("handler-fails-but-abort-must-fire")).not.toThrow(); expect(abort).toHaveBeenCalledTimes(1); warn.mockRestore(); }); it("requireExplicitMessageTarget defaults to isSubagentSessionKey(sessionKey) when undefined", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); await createCopilotToolBridge({ agentId: "agent-1", // No requireExplicitMessageTarget; sessionKey looks like a // subagent key so the default must be true. Mirrors PI // attempt.ts:1097-1098. attemptParams: { sessionKey: "subagent:envelope:abc" } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); const opts = getOpts(); // We don't assert the exact boolean (subagent detection is owned // by isSubagentSessionKey) — only that the bridge consulted the // helper rather than emitting `undefined`. expect(typeof opts.requireExplicitMessageTarget).toBe("boolean"); }); }); describe("sandbox forwarding (PR #86155 [P1])", () => { function makeSandboxStub(overrides: Partial = {}): SandboxContext { return { enabled: true, workspaceAccess: "ro", workspaceDir: "/sandbox/copy", agentWorkspaceDir: "/sandbox/agent", scopeKey: "agent-1:session-1", sessionKey: "session-1", backend: { kind: "local" } as never, cfg: {} as never, ...overrides, } as unknown as SandboxContext; } it("defaults sandbox to undefined and derives spawnWorkspaceDir from workspaceDir when no sandbox is passed (back-compat)", async () => { const createOpenClawCodingTools = vi.fn(async () => [makeTool()]); await createCopilotToolBridge({ agentId: "agent-1", createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", sessionKey: "session-1", workspaceDir: "/workspace", }); const opts = (createOpenClawCodingTools.mock.calls[0] as unknown[] | undefined)?.[0] as { sandbox?: unknown; spawnWorkspaceDir?: unknown; workspaceDir?: unknown; }; expect(opts.sandbox).toBeUndefined(); expect(opts.workspaceDir).toBe("/workspace"); // resolveAttemptSpawnWorkspaceDir returns undefined for the // no-sandbox path; the back-compat fallback emits that. expect(opts.spawnWorkspaceDir).toBeUndefined(); }); it("forwards an explicit sandbox and spawnWorkspaceDir verbatim to createOpenClawCodingTools", async () => { const sandbox = makeSandboxStub(); const createOpenClawCodingTools = vi.fn(async () => [makeTool()]); await createCopilotToolBridge({ agentId: "agent-1", createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sandbox, sessionId: "session-1", sessionKey: "session-1", spawnWorkspaceDir: "/original-workspace", workspaceDir: "/sandbox/copy", }); const opts = (createOpenClawCodingTools.mock.calls[0] as unknown[] | undefined)?.[0] as { sandbox?: unknown; spawnWorkspaceDir?: unknown; workspaceDir?: unknown; }; expect(opts.sandbox).toBe(sandbox); expect(opts.workspaceDir).toBe("/sandbox/copy"); expect(opts.spawnWorkspaceDir).toBe("/original-workspace"); }); it("derives spawnWorkspaceDir from sandbox when caller omits it (fallback path)", async () => { const sandbox = makeSandboxStub({ workspaceAccess: "ro" }); const createOpenClawCodingTools = vi.fn(async () => [makeTool()]); await createCopilotToolBridge({ agentId: "agent-1", createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sandbox, sessionId: "session-1", sessionKey: "session-1", workspaceDir: "/sandbox/copy", }); const opts = (createOpenClawCodingTools.mock.calls[0] as unknown[] | undefined)?.[0] as { spawnWorkspaceDir?: unknown; }; // Fallback derives spawnWorkspaceDir from (effective) workspaceDir // since the caller didn't pre-compute one. For a ro/none sandbox // this yields the effective dir (= sandbox copy). Production // callers (attempt.ts) always pre-compute spawnWorkspaceDir from // the original workspace; the fallback is for test fixtures. expect(opts.spawnWorkspaceDir).toBe("/sandbox/copy"); }); }); // The Copilot bridge mirrors the PI runner's disable/raw/allowlist // gates locally (codex-precedent at // extensions/codex/src/app-server/run-attempt.ts:3813,3906-3939,4220-4234) // so a Copilot run cannot expose the SDK any tool that the same // OpenClaw attempt would suppress. These tests pin the contract. describe("tool-surface gating (PR #86155 [P1] round-6)", () => { it("short-circuits when attemptParams.disableTools is true and never calls createOpenClawCodingTools", async () => { const createOpenClawCodingTools = vi.fn(async () => [makeTool()]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { disableTools: true } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result).toEqual({ sdkTools: [], sourceTools: [] }); expect(createOpenClawCodingTools).toHaveBeenCalledTimes(0); }); it('short-circuits raw model runs signalled via promptMode: "none"', async () => { const createOpenClawCodingTools = vi.fn(async () => [makeTool()]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { promptMode: "none" } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result).toEqual({ sdkTools: [], sourceTools: [] }); expect(createOpenClawCodingTools).toHaveBeenCalledTimes(0); }); it("short-circuits raw model runs signalled via modelRun: true", async () => { const createOpenClawCodingTools = vi.fn(async () => [makeTool()]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { modelRun: true } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result).toEqual({ sdkTools: [], sourceTools: [] }); expect(createOpenClawCodingTools).toHaveBeenCalledTimes(0); }); it("filters constructed tools to exactly the allowlist when toolsAllow is narrow", async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "read" }), makeTool({ name: "edit" }), makeTool({ name: "message" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["read"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["read"]); expect(result.sdkTools.map((tool) => tool.name)).toEqual(["read"]); }); it("returns no tools when toolsAllow is an empty list and nothing is forced", async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "read" }), makeTool({ name: "edit" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: [] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools).toEqual([]); expect(result.sdkTools).toEqual([]); }); it('merges "message" into an empty allowlist when forceMessageTool is true', async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "read" }), makeTool({ name: "message" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: [], forceMessageTool: true } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["message"]); }); it('merges "message" into an empty allowlist when sourceReplyDeliveryMode is message_tool_only', async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "read" }), makeTool({ name: "message" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: [], sourceReplyDeliveryMode: "message_tool_only", } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["message"]); }); it('appends "message" to a narrow allowlist when forceMessageTool is true', async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "read" }), makeTool({ name: "edit" }), makeTool({ name: "message" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["read"], forceMessageTool: true, } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name).toSorted()).toEqual(["message", "read"]); }); it("does NOT force a message tool when disableMessageTool is true (disable wins over force)", async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "read" }), makeTool({ name: "message" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["read"], forceMessageTool: true, disableMessageTool: true, } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["read"]); }); it("leaves the tool list unchanged when toolsAllow is undefined", async () => { const tools = [makeTool({ name: "read" }), makeTool({ name: "edit" })]; const createOpenClawCodingTools = vi.fn(async () => tools); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: {} as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["read", "edit"]); }); it("leaves the tool list unchanged when toolsAllow contains a wildcard", async () => { const tools = [makeTool({ name: "read" }), makeTool({ name: "edit" })]; const createOpenClawCodingTools = vi.fn(async () => tools); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["*"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["read", "edit"]); }); it("runs duplicate detection AFTER allowlist filtering so a suppressed duplicate does not fail a narrow run", async () => { // The raw construction returns duplicate "edit" entries, but the // allowlist excludes "edit" entirely. PI parity: the duplicate // never reaches the SDK, so the bridge must not throw. const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "read" }), makeTool({ name: "edit" }), makeTool({ name: "edit" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["read"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["read"]); }); it("still throws when the filtered tool set itself contains duplicates", async () => { // Both copies of "read" survive the allowlist, so the duplicate // truly reaches the SDK and the bridge must fail loudly. await expect( createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["read"] } as never, createOpenClawCodingTools: async () => [ makeTool({ name: "read" }), makeTool({ name: "read" }), ], modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }), ).rejects.toThrow("duplicate tool names: read"); }); }); // Codex extension already normalises a small set of tool-name aliases // before allowlist matching // (extensions/codex/src/app-server/dynamic-tool-profile.ts:17-30 // + extensions/codex/src/app-server/run-attempt.test.ts:2062). The // Copilot bridge mirrors the same two aliases so a `toolsAllow: ["bash"]` // or `toolsAllow: ["apply-patch"]` resolves to the underlying tool. describe("tool-name aliases (PR #86155 [P1] round-7)", () => { it('matches the "exec" tool when toolsAllow contains "bash"', async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "exec" }), makeTool({ name: "read" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["bash"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec"]); }); it('matches the "apply_patch" tool when toolsAllow contains "apply-patch"', async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "apply_patch" }), makeTool({ name: "read" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["apply-patch"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["apply_patch"]); }); it("normalises case so uppercase/whitespace aliases still resolve", async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "exec" }), makeTool({ name: "apply_patch" }), makeTool({ name: "read" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: [" BASH ", "Apply-Patch", "READ"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name).toSorted()).toEqual([ "apply_patch", "exec", "read", ]); }); it("continues to match canonical names directly (no double-aliasing)", async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "exec" }), makeTool({ name: "apply_patch" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["exec", "apply_patch"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name).toSorted()).toEqual([ "apply_patch", "exec", ]); }); it("honors core group allowlists through the shared embedded-runner filter", async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "read" }), makeTool({ name: "edit" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["group:fs"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name).toSorted()).toEqual(["edit", "read"]); }); it("keeps plugin tools for plugin group allowlists", async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "memory_search", pluginId: "active-memory" } as never), makeTool({ name: "read" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["group:plugins"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["memory_search"]); }); it("keeps core tools available for glob allowlists", async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "web_fetch" }), makeTool({ name: "read" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["web_*"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["web_fetch"]); const options = (createOpenClawCodingTools.mock.calls[0] as unknown[] | undefined)?.[0] as { toolConstructionPlan?: { includeOpenClawTools?: boolean }; }; expect(options?.toolConstructionPlan?.includeOpenClawTools).toBe(true); }); it("does not keep apply_patch for a write-only allowlist", async () => { const createOpenClawCodingTools = vi.fn(async () => [ makeTool({ name: "write" }), makeTool({ name: "apply_patch" }), ]); const result = await createCopilotToolBridge({ agentId: "agent-1", attemptParams: { toolsAllow: ["write"] } as never, createOpenClawCodingTools, modelId: "gpt-4o", modelProvider: "github-copilot", sessionId: "session-1", }); expect(result.sourceTools.map((tool) => tool.name)).toEqual(["write"]); const options = (createOpenClawCodingTools.mock.calls[0] as unknown[] | undefined)?.[0] as { toolConstructionPlan?: { includeShellTools?: boolean }; }; expect(options?.toolConstructionPlan?.includeShellTools).toBe(false); }); }); }); describe("convertOpenClawToolToSdkTool", () => { it("throws on empty and non-string names", () => { expect(() => convertOpenClawToolToSdkTool(makeTool({ name: "" as never }), {})).toThrow( "tool name must be a non-empty string", ); expect(() => convertOpenClawToolToSdkTool(makeTool({ name: 42 as never }), {})).toThrow( "tool name must be a non-empty string", ); }); it("throws on non-function execute", () => { expect(() => convertOpenClawToolToSdkTool(makeTool({ execute: "nope" as never }), {})).toThrow( "must define an execute function", ); }); it("preserves name, description, and parameters exactly", () => { const parameters = { properties: { path: { type: "string" } }, type: "object", }; const sourceTool = makeTool({ description: "Read a file", name: "read_file", parameters: parameters as never, }); const result = convertOpenClawToolToSdkTool(sourceTool, {}); expect(result.name).toBe("read_file"); expect(result.description).toBe("Read a file"); expect(result.parameters).toBe(parameters); }); it("sets skipPermission: true so OpenClaw's wrapped-tool internal enforcement handles permission decisions (PI-parity model)", () => { // Per the harness docs: every bridged OpenClaw tool comes from // `createOpenClawCodingTools`, which already wraps each tool with // `wrapToolWithBeforeToolCallHook` (loop detection, trusted plugin // policies, before-tool-call hooks, two-phase plugin approvals via // the gateway). Asking the SDK to run its own `onPermissionRequest` // for kind: "custom-tool" would either short-circuit OpenClaw's // richer enforcement (allow-all) or block every call (reject-all). // Setting `skipPermission: true` lets the wrapped execute() run // OpenClaw's hook with the right context — mirrors codex // (`extensions/codex/src/app-server/dynamic-tools.ts`). const result = convertOpenClawToolToSdkTool(makeTool(), {}) as SdkTool & { skipPermission?: boolean; }; expect(result.skipPermission).toBe(true); }); it("marks every bridged tool as overridesBuiltInTool so OpenClaw owns names that collide with Copilot CLI built-ins (edit/read/write/bash/...)", () => { // Real-world dogfood found that openclaw's createOpenClawCodingTools // returns a tool named `edit`, which the bundled Copilot CLI also ships // as a built-in. The SDK rejects the registration unless the external // tool is explicitly marked as an override. for (const name of ["edit", "read", "write", "bash", "live_echo"]) { const result = convertOpenClawToolToSdkTool(makeTool({ name }), {}) as SdkTool & { overridesBuiltInTool?: boolean; }; expect(result.overridesBuiltInTool).toBe(true); } }); it("returns a failure result when the signal is already aborted", async () => { const controller = new AbortController(); controller.abort(); const sourceTool = makeTool(); const sdkTool = convertOpenClawToolToSdkTool(sourceTool, { abortSignal: controller.signal }); const result = await runSdkTool(sdkTool, {}); expect(sourceTool.execute).toHaveBeenCalledTimes(0); expect(result).toMatchObject({ resultType: "failure", textResultForLlm: "[copilot-tool-bridge] aborted before execution", }); expect(getError(result as ToolResultObject)).toBe( "[copilot-tool-bridge] aborted before execution", ); }); it("calls beforeExecute with the invocation context before execute", async () => { const beforeExecute = vi.fn(async () => undefined); const sourceTool = makeTool(); const sdkTool = convertOpenClawToolToSdkTool(sourceTool, { beforeExecute }); const invocation = makeInvocation({ toolCallId: "call-42" }); const args = { value: "input" }; await runSdkTool(sdkTool, args, invocation); expect(beforeExecute).toHaveBeenCalledTimes(1); expect(beforeExecute).toHaveBeenCalledWith({ args, invocation, sourceTool, toolCallId: "call-42", toolName: "tool-a", }); expect(beforeExecute.mock.invocationCallOrder[0]).toBeLessThan( sourceTool.execute.mock.invocationCallOrder[0], ); }); it("returns a failure result when beforeExecute throws", async () => { const error = new Error("permission denied"); const sourceTool = makeTool(); const sdkTool = convertOpenClawToolToSdkTool(sourceTool, { beforeExecute: vi.fn(async () => { throw error; }), }); const result = await runSdkTool(sdkTool, {}); expect(sourceTool.execute).toHaveBeenCalledTimes(0); expect(result).toMatchObject({ resultType: "failure", textResultForLlm: "[copilot-tool-bridge] beforeExecute failed for tool 'tool-a': permission denied", }); expect(getError(result as ToolResultObject)).toBe(error.message); }); it("calls prepareArguments and passes the prepared args and toolCallId to execute", async () => { const preparedArgs = { value: "prepared" }; const prepareArguments = vi.fn(() => preparedArgs); const sourceTool = makeTool({ prepareArguments }); const sdkTool = convertOpenClawToolToSdkTool(sourceTool, {}); await runSdkTool(sdkTool, { value: "raw" }, makeInvocation({ toolCallId: "call-99" })); expect(prepareArguments).toHaveBeenCalledTimes(1); expect(prepareArguments).toHaveBeenCalledWith({ value: "raw" }); expect(sourceTool.execute).toHaveBeenCalledWith("call-99", preparedArgs, undefined, undefined); }); it("returns a failure result when prepareArguments throws", async () => { const error = new Error("bad args"); const sourceTool = makeTool({ prepareArguments: vi.fn(() => { throw error; }), }); const sdkTool = convertOpenClawToolToSdkTool(sourceTool, {}); const result = await runSdkTool(sdkTool, {}); expect(sourceTool.execute).toHaveBeenCalledTimes(0); expect(result).toMatchObject({ resultType: "failure", textResultForLlm: "[copilot-tool-bridge] prepareArguments failed for tool 'tool-a': bad args", }); expect(getError(result as ToolResultObject)).toBe(error.message); }); it("returns success with empty text when content is missing", async () => { const sourceTool = makeTool({}, { details: null }); const sdkTool = convertOpenClawToolToSdkTool(sourceTool, {}); const result = await runSdkTool(sdkTool, {}); expect(result).toEqual({ resultType: "success", textResultForLlm: "" }); }); it("converts single text content to an exact textResultForLlm", async () => { const sdkTool = convertOpenClawToolToSdkTool( makeTool({}, { content: [{ text: "hello", type: "text" }], details: null }), {}, ); const result = await runSdkTool(sdkTool, {}); expect(result).toEqual({ resultType: "success", textResultForLlm: "hello" }); }); it("joins multiple text blocks with newlines", async () => { const sdkTool = convertOpenClawToolToSdkTool( makeTool( {}, { content: [ { text: "first", type: "text" }, { text: "second", type: "text" }, { text: "third", type: "text" }, ], details: null, }, ), {}, ); const result = await runSdkTool(sdkTool, {}); expect(result).toEqual({ resultType: "success", textResultForLlm: "first\nsecond\nthird" }); }); it("converts image content into binaryResultsForLlm while preserving text", async () => { const sdkTool = convertOpenClawToolToSdkTool( makeTool( {}, { content: [ { text: "preview", type: "text" }, { data: "base64-data", mimeType: "image/png", type: "image" }, ], details: null, }, ), {}, ); const result = await runSdkTool(sdkTool, {}); expect(result).toEqual({ binaryResultsForLlm: [ { base64Data: "base64-data", data: "base64-data", mimeType: "image/png", type: "image", }, ], resultType: "success", textResultForLlm: "preview", }); }); it("returns a failure result for unsupported content shapes", async () => { const sdkTool = convertOpenClawToolToSdkTool( makeTool( {}, { content: [{ type: "resource" }], details: null, }, ), {}, ); const result = await runSdkTool(sdkTool, {}); expect(result).toMatchObject({ resultType: "failure", textResultForLlm: "[copilot-tool-bridge] unsupported AgentToolResult content shape: resource", }); expect(getError(result as ToolResultObject)).toBe( "[copilot-tool-bridge] unsupported AgentToolResult content shape: resource", ); }); it("returns a failure result when execute throws and preserves the error", async () => { const error = new Error("tool exploded"); const sourceTool = makeTool({ execute: vi.fn(async () => { throw error; }), }); const sdkTool = convertOpenClawToolToSdkTool(sourceTool, {}); const result = await runSdkTool(sdkTool, {}); expect(result).toMatchObject({ resultType: "failure", textResultForLlm: "[copilot-tool-bridge] tool 'tool-a' failed: tool exploded", }); expect(getError(result as ToolResultObject)).toBe(error.message); }); it("runs default tools in parallel", async () => { const first = createDeferred<{ content: Array<{ text: string; type: string }>; details: null; }>(); const second = createDeferred<{ content: Array<{ text: string; type: string }>; details: null; }>(); const execute = vi .fn() .mockImplementationOnce(async () => first.promise) .mockImplementationOnce(async () => second.promise); const sourceTool = makeTool({ execute }); const sdkTool = convertOpenClawToolToSdkTool(sourceTool, {}); const firstRun = runSdkTool(sdkTool, {}, makeInvocation({ toolCallId: "call-1" })); const secondRun = runSdkTool(sdkTool, {}, makeInvocation({ toolCallId: "call-2" })); await flushAsync(); expect(execute).toHaveBeenCalledTimes(2); first.resolve({ content: [{ text: "one", type: "text" }], details: null }); second.resolve({ content: [{ text: "two", type: "text" }], details: null }); await expect(Promise.all([firstRun, secondRun])).resolves.toEqual([ { resultType: "success", textResultForLlm: "one" }, { resultType: "success", textResultForLlm: "two" }, ]); }); it("serializes sequential tools so the second call waits for the first", async () => { const first = createDeferred<{ content: Array<{ text: string; type: string }>; details: null; }>(); const second = createDeferred<{ content: Array<{ text: string; type: string }>; details: null; }>(); const execute = vi .fn() .mockImplementationOnce(async () => first.promise) .mockImplementationOnce(async () => second.promise); const sourceTool = makeTool({ execute, executionMode: "sequential" }); const sdkTool = convertOpenClawToolToSdkTool(sourceTool, {}); const firstRun = runSdkTool(sdkTool, {}, makeInvocation({ toolCallId: "call-1" })); const secondRun = runSdkTool(sdkTool, {}, makeInvocation({ toolCallId: "call-2" })); await flushAsync(); expect(execute).toHaveBeenCalledTimes(1); first.resolve({ content: [{ text: "one", type: "text" }], details: null }); await firstRun; await flushAsync(); expect(execute).toHaveBeenCalledTimes(2); second.resolve({ content: [{ text: "two", type: "text" }], details: null }); await expect(Promise.all([firstRun, secondRun])).resolves.toEqual([ { resultType: "success", textResultForLlm: "one" }, { resultType: "success", textResultForLlm: "two" }, ]); }); it("returns a failure result when execute observes an abort after start", async () => { const controller = new AbortController(); const sourceTool = makeTool({ execute: vi.fn( (_toolCallId: string, _args: unknown, signal?: AbortSignal) => new Promise((_, reject) => { signal?.addEventListener( "abort", () => { reject(new Error("aborted during execute")); }, { once: true }, ); }), ), }); const sdkTool = convertOpenClawToolToSdkTool(sourceTool, { abortSignal: controller.signal }); const resultPromise = runSdkTool(sdkTool, {}); await flushAsync(); controller.abort(); const result = await resultPromise; expect(sourceTool.execute).toHaveBeenCalledTimes(1); expect(result).toMatchObject({ resultType: "failure", textResultForLlm: "[copilot-tool-bridge] tool 'tool-a' failed: aborted during execute", }); expect(getError(result as ToolResultObject)).toBe("aborted during execute"); }); });