Files
openclaw/extensions/copilot/src/tool-bridge.test.ts
Ramrajprabu f3cfd752d3 feat(copilot): add GitHub Copilot agent runtime
Adds the opt-in bundled GitHub Copilot agent runtime, pinned SDK install path, docs/inventory, SDK/tool/sandbox/auth wiring, and replay/tool-safety fixes.

Verification:
- Local: git diff --check; fnm exec --using 24.15.0 pnpm tsgo:extensions; fnm exec --using 24.15.0 pnpm check:test-types; fnm exec --using 24.15.0 pnpm build.
- Autoreview local: clean for the replay-safety fix; branch autoreview engine returned empty output twice, so local autoreview plus local/Crabbox/CI proof was used.
- Crabbox focused Copilot: run_2c0db9f48a4a, 19 files / 485 tests passed.
- Crabbox additional boundary shard: run_26a246a1aa24, prompt snapshots and plugin SDK boundary/export checks passed.
- Crabbox live Copilot: run_d128e4048b4e, real gpt-4.1 turn with live_echo phase-1-green and clean session-file check.
- GitHub checks: green on head 7cc8657e0d, including Dependency Guard after exact-head approval.

Co-authored-by: Ramraj Balasubramanian <ramrajba@microsoft.com>
2026-05-29 05:15:22 +01:00

1405 lines
51 KiB
TypeScript

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<typeof vi.fn>;
prepareArguments?: ReturnType<typeof vi.fn>;
};
function createDeferred<T>() {
let rejectPromise: ((reason?: unknown) => void) | undefined;
let resolvePromise: ((value: T | PromiseLike<T>) => void) | undefined;
const promise = new Promise<T>((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> = {}): ToolInvocation {
return {
arguments: { value: "input" },
sessionId: "session-1",
toolCallId: "call-1",
toolName: "tool-a",
...overrides,
};
}
function makeTool(
overrides: Partial<FakeTool> = {},
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;
}
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<string, unknown>;
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> = {}): 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 sdkTool.handler({}, makeInvocation());
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 sdkTool.handler(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 sdkTool.handler({}, makeInvocation());
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 sdkTool.handler({ 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 sdkTool.handler({}, makeInvocation());
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 sdkTool.handler({}, makeInvocation());
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 sdkTool.handler({}, makeInvocation());
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 sdkTool.handler({}, makeInvocation());
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 sdkTool.handler({}, makeInvocation());
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 sdkTool.handler({}, makeInvocation());
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 sdkTool.handler({}, makeInvocation());
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 = sdkTool.handler({}, makeInvocation({ toolCallId: "call-1" }));
const secondRun = sdkTool.handler({}, 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 = sdkTool.handler({}, makeInvocation({ toolCallId: "call-1" }));
const secondRun = sdkTool.handler({}, 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<never>((_, reject) => {
signal?.addEventListener(
"abort",
() => {
reject(new Error("aborted during execute"));
},
{ once: true },
);
}),
),
});
const sdkTool = convertOpenClawToolToSdkTool(sourceTool, { abortSignal: controller.signal });
const resultPromise = sdkTool.handler({}, makeInvocation());
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");
});
});