mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-04 18:24:06 +00:00
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>
1405 lines
51 KiB
TypeScript
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");
|
|
});
|
|
});
|