mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: add typed MCP code-mode API (#88678)
* feat: add typed MCP code-mode API * fix: stabilize code-mode namespace drain * fix: preserve code-mode run cap * fix: reserve code-mode snapshot capacity
This commit is contained in:
committed by
GitHub
parent
d1b514af2e
commit
4150c6ff82
@@ -329,8 +329,12 @@ type CodeModeFailedResult = {
|
||||
};
|
||||
```
|
||||
|
||||
`exec` returns `waiting` when the QuickJS VM suspends with resumable state. The
|
||||
result includes a `runId` for `wait`.
|
||||
`exec` returns `waiting` when the QuickJS VM suspends with resumable state that
|
||||
still needs a model-visible continuation. The result includes a `runId` for
|
||||
`wait`. Namespace bridge calls, including MCP namespace calls, are auto-drained
|
||||
inside the same `exec`/`wait` call while they are ready, so a compact code block
|
||||
can inspect `$api()` and call an MCP tool without forcing one model tool call per
|
||||
namespace await.
|
||||
|
||||
`exec` returns `completed` only when the guest VM has no pending work and the
|
||||
final value is JSON-compatible after OpenClaw's output adapter runs.
|
||||
@@ -436,9 +440,14 @@ const hits = await tools.web_search({ query: "OpenClaw code mode" });
|
||||
```
|
||||
|
||||
MCP catalog entries are not callable through `tools.call(...)` or convenience
|
||||
functions in code mode. Use the generated `MCP` namespace instead:
|
||||
functions in code mode. They are exposed only through the generated `MCP`
|
||||
namespace, which includes TypeScript-style API headers for discovery:
|
||||
|
||||
```typescript
|
||||
const servers = await MCP.$api();
|
||||
const githubApi = await MCP.github.$api();
|
||||
const createIssueApi = await MCP.github.$api("createIssue", { schema: true });
|
||||
|
||||
const issue = await MCP.github.createIssue({
|
||||
owner: "openclaw",
|
||||
repo: "openclaw",
|
||||
@@ -446,8 +455,40 @@ const issue = await MCP.github.createIssue({
|
||||
});
|
||||
|
||||
const snapshot = await MCP.chromeDevtools.takeSnapshot({ output: "markdown" });
|
||||
const resource = await MCP.docs.resources.read("memo://one");
|
||||
const prompt = await MCP.docs.prompts.get("brief", { topic: "release" });
|
||||
const resource = await MCP.docs.resources.read({ uri: "memo://one" });
|
||||
const prompt = await MCP.docs.prompts.get({
|
||||
name: "brief",
|
||||
arguments: { topic: "release" },
|
||||
});
|
||||
```
|
||||
|
||||
`MCP.<server>.$api()` returns a compact header inferred from MCP tool metadata:
|
||||
|
||||
```typescript
|
||||
type McpToolResult = {
|
||||
content?: unknown[];
|
||||
structuredContent?: unknown;
|
||||
isError?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
declare namespace MCP.github {
|
||||
/** Return this TypeScript-style API header. */
|
||||
function $api(toolName?: string, options?: { schema?: boolean }): Promise<McpApiHeader>;
|
||||
|
||||
/**
|
||||
* Create a GitHub issue.
|
||||
* @param owner Repository owner
|
||||
* @param repo Repository name
|
||||
* @param title Issue title
|
||||
*/
|
||||
function createIssue(input: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
}): Promise<McpToolResult>;
|
||||
}
|
||||
```
|
||||
|
||||
The guest runtime must not expose host objects directly. Inputs and outputs cross
|
||||
@@ -493,8 +534,9 @@ run follows this path:
|
||||
6. Guest calls suspend through the worker bridge, resolve the namespace path on
|
||||
the host, map the call to a declared plugin-owned catalog tool, and execute
|
||||
that tool through `ToolSearchRuntime.call`.
|
||||
7. `wait` resumes the same namespace runtime when a code-mode run suspended on
|
||||
nested tool work.
|
||||
7. OpenClaw auto-drains ready namespace bridge calls inside the active
|
||||
`exec`/`wait` tool call. If namespace work is still pending at the timeout or
|
||||
the guest yields explicitly, `wait` resumes the same namespace runtime later.
|
||||
8. Plugin rollback or uninstall calls `clearCodeModeNamespacesForPlugin(pluginId)`
|
||||
so stale globals do not survive a failed plugin load.
|
||||
|
||||
@@ -701,9 +743,10 @@ This prevents recursion and keeps the model-facing contract narrow.
|
||||
|
||||
MCP entries stay in the run-scoped catalog so policy, approvals, hooks,
|
||||
telemetry, transcript projection, and exact tool ids remain shared with normal
|
||||
tool execution. The guest-facing `tools.call(...)` bridge rejects MCP entries;
|
||||
the generated `MCP.<server>.<tool>(...)` namespace resolves back to the exact
|
||||
catalog id and then dispatches through the same executor path.
|
||||
tool execution. The guest-facing `ALL_TOOLS`, `tools.search(...)`,
|
||||
`tools.describe(...)`, and `tools.call(...)` views omit MCP entries. The
|
||||
generated `MCP.<server>.<tool>({ ...input })` namespace resolves back to the
|
||||
exact catalog id and then dispatches through the same executor path.
|
||||
|
||||
## Tool Search interaction
|
||||
|
||||
@@ -716,8 +759,9 @@ When `tools.codeMode.enabled` is true and code mode activates:
|
||||
or `tool_call` as model-visible tools.
|
||||
- The same cataloging idea moves inside the guest runtime.
|
||||
- The guest runtime receives compact `ALL_TOOLS` metadata and search, describe,
|
||||
and call helpers.
|
||||
- MCP calls use the generated `MCP` namespace instead of `tools.call(...)`.
|
||||
and call helpers for non-MCP tools.
|
||||
- MCP calls use the generated `MCP` namespace and its `$api()` headers instead
|
||||
of `tools.call(...)`.
|
||||
- Nested calls dispatch through the same OpenClaw executor path that Tool Search
|
||||
uses.
|
||||
|
||||
@@ -934,11 +978,13 @@ Code mode coverage should prove:
|
||||
active for the run
|
||||
- raw no-tool runs, `disableTools`, and empty allowlists do not trigger code-mode
|
||||
payload enforcement
|
||||
- all effective tools appear in `ALL_TOOLS`
|
||||
- all effective non-MCP tools appear in `ALL_TOOLS`
|
||||
- denied tools do not appear in `ALL_TOOLS`
|
||||
- `tools.search`, `tools.describe`, and `tools.call` work for OpenClaw tools
|
||||
- MCP namespace calls work for visible MCP tools and direct MCP `tools.call`
|
||||
attempts fail closed
|
||||
- MCP namespace `$api()` returns TypeScript-style headers inferred from MCP
|
||||
schemas
|
||||
- MCP namespace calls work for visible MCP tools with one object input, while
|
||||
direct MCP catalog entries are absent from `tools.*`
|
||||
- Tool Search control tools are hidden from both the model surface and the hidden
|
||||
catalog
|
||||
- nested calls preserve approval and hook behavior
|
||||
@@ -968,14 +1014,16 @@ Run these as integration or end-to-end tests when changing the runtime:
|
||||
7. In `exec`, read `ALL_TOOLS` and assert the effective test tools are present.
|
||||
8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`,
|
||||
`tools.describe`, and `tools.call`.
|
||||
9. In `exec`, call MCP tools through `MCP.<server>.<tool>(...)` and assert direct
|
||||
MCP `tools.call(...)` attempts fail.
|
||||
10. Assert denied tools are absent and cannot be called by guessed id.
|
||||
11. Start a nested tool call that resolves after `exec` returns `waiting`.
|
||||
12. Call `wait` and assert the restored VM receives the tool result.
|
||||
13. Assert the final answer contains output produced after restore.
|
||||
14. Assert timeout, abort, and snapshot expiry clean up runtime state.
|
||||
15. Export trajectory and assert nested calls are visible under the parent
|
||||
9. In `exec`, call `MCP.$api()` and `MCP.<server>.$api()` and assert the headers
|
||||
describe visible MCP tools.
|
||||
10. In `exec`, call MCP tools through `MCP.<server>.<tool>({ ...input })` and
|
||||
assert direct MCP catalog entries are absent from `ALL_TOOLS` and `tools.*`.
|
||||
11. Assert denied tools are absent and cannot be called by guessed id.
|
||||
12. Start a nested tool call that resolves after `exec` returns `waiting`.
|
||||
13. Call `wait` and assert the restored VM receives the tool result.
|
||||
14. Assert the final answer contains output produced after restore.
|
||||
15. Assert timeout, abort, and snapshot expiry clean up runtime state.
|
||||
16. Export trajectory and assert nested calls are visible under the parent
|
||||
code-mode call.
|
||||
|
||||
Docs-only changes to this page should still run `pnpm check:docs`.
|
||||
|
||||
@@ -174,6 +174,7 @@ const QA_SKILL_WORKSHOP_REVIEW_PROMPT_RE = /Review transcript for durable skill
|
||||
const QA_RELEASE_AUDIT_PROMPT_RE = /release readiness audit for the small project/i;
|
||||
const QA_TOOL_SEARCH_PROMPT_RE = /tool search qa check/i;
|
||||
const QA_TOOL_SEARCH_FAILURE_PROMPT_RE = /tool search qa failure/i;
|
||||
const QA_MCP_CODE_MODE_PROMPT_RE = /mcp code mode qa check/i;
|
||||
|
||||
type MockScenarioState = {
|
||||
subagentFanoutPhase: number;
|
||||
@@ -1806,6 +1807,38 @@ async function buildResponsesPayload(
|
||||
return buildToolCallEventsWithArgs(targetTool, plannedArgs);
|
||||
}
|
||||
}
|
||||
if (QA_MCP_CODE_MODE_PROMPT_RE.test(allInputText)) {
|
||||
if (!toolOutput && hasDeclaredTool(body, "exec")) {
|
||||
return buildToolCallEventsWithArgs("exec", {
|
||||
language: "javascript",
|
||||
code: [
|
||||
"const rootApi = await MCP.$api();",
|
||||
'const api = await MCP.fixture.$api("lookupNote", { schema: true });',
|
||||
'const result = await MCP.fixture.lookupNote({ id: "alpha" });',
|
||||
"return {",
|
||||
' marker: "MCP_CODE_MODE_TOOL_RESULT",',
|
||||
" rootServers: rootApi.servers,",
|
||||
" headerHasLookup: api.header.includes('function lookupNote'),",
|
||||
" schemaKeys: Object.keys(api.schemas),",
|
||||
" resultText: result.content?.[0]?.text,",
|
||||
" allHasMcp: ALL_TOOLS.some((tool) => tool.source === 'mcp'),",
|
||||
"};",
|
||||
].join("\n"),
|
||||
});
|
||||
}
|
||||
if (
|
||||
toolJson?.status === "waiting" &&
|
||||
typeof toolJson.runId === "string" &&
|
||||
hasDeclaredTool(body, "wait")
|
||||
) {
|
||||
return buildToolCallEventsWithArgs("wait", { runId: toolJson.runId });
|
||||
}
|
||||
if (/MCP_CODE_MODE_TOOL_RESULT|fixture-note-alpha/.test(toolOutput)) {
|
||||
return buildAssistantEvents(
|
||||
"MCP_CODE_MODE_OK unclear=none improvement=virtual-header-files-would-avoid-the-first-api-call",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
allInputText.includes(QA_SUBAGENT_DIRECT_FALLBACK_MARKER) &&
|
||||
/Internal task completion event/i.test(allInputText)
|
||||
|
||||
358
scripts/mcp-code-mode-gateway-e2e.ts
Normal file
358
scripts/mcp-code-mode-gateway-e2e.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { setTimeout as setNodeTimeout, clearTimeout as clearNodeTimeout } from "node:timers";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { startQaMockOpenAiServer } from "../extensions/qa-lab/src/providers/mock-openai/server.js";
|
||||
import { stageQaMockAuthProfiles } from "../extensions/qa-lab/src/providers/shared/mock-auth.js";
|
||||
import { buildQaGatewayConfig } from "../extensions/qa-lab/src/qa-gateway-config.js";
|
||||
import { resetConfigRuntimeState } from "../src/config/config.js";
|
||||
import { startGatewayServer } from "../src/gateway/server.js";
|
||||
import { readBoundedResponseText } from "./lib/bounded-response.ts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
server.close((error) => (error ? reject(error) : resolve(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init: RequestInit = {}): Promise<unknown> {
|
||||
const timeoutMs = 180_000;
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setNodeTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setNodeTimeout(() => {
|
||||
const error = Object.assign(new Error(`HTTP request to ${url} timed out`), {
|
||||
code: "ETIMEDOUT",
|
||||
});
|
||||
controller.abort(error);
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
fetch(url, { ...init, signal: controller.signal }),
|
||||
timeoutPromise,
|
||||
]);
|
||||
const text = await readBoundedResponseText(response, url, 1024 * 1024, {
|
||||
createTooLargeError(message) {
|
||||
return Object.assign(new Error(message), { code: "ETOOBIG" });
|
||||
},
|
||||
formatTooLargeMessage(targetUrl, byteLimit) {
|
||||
return `HTTP response from ${targetUrl} exceeded ${byteLimit} bytes`;
|
||||
},
|
||||
timeoutPromise,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} from ${url}: ${text}`);
|
||||
}
|
||||
return text ? JSON.parse(text) : {};
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearNodeTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function outputText(response: unknown): string {
|
||||
const output = (response as { output?: Array<{ type?: unknown; content?: unknown }> }).output;
|
||||
if (!Array.isArray(output)) {
|
||||
return "";
|
||||
}
|
||||
return output
|
||||
.flatMap((item) => {
|
||||
if (item.type !== "message" || !Array.isArray(item.content)) {
|
||||
return [];
|
||||
}
|
||||
return item.content.flatMap((piece) => {
|
||||
if (!piece || typeof piece !== "object") {
|
||||
return [];
|
||||
}
|
||||
const record = piece as { text?: unknown };
|
||||
return typeof record.text === "string" ? [record.text] : [];
|
||||
});
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
if (!needle) {
|
||||
return 0;
|
||||
}
|
||||
let count = 0;
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const next = haystack.indexOf(needle, offset);
|
||||
if (next < 0) {
|
||||
return count;
|
||||
}
|
||||
count += 1;
|
||||
offset = next + needle.length;
|
||||
}
|
||||
}
|
||||
|
||||
async function readSessionLogMentions(stateDir: string): Promise<Record<string, number>> {
|
||||
const sessionsDir = path.join(stateDir, "agents", "qa", "sessions");
|
||||
const mentions = {
|
||||
apiCall: 0,
|
||||
mcpNamespace: 0,
|
||||
mcpTool: 0,
|
||||
toolSearchPollution: 0,
|
||||
};
|
||||
const files = await fs.readdir(sessionsDir).catch(() => []);
|
||||
for (const file of files.filter((candidate) => candidate.endsWith(".jsonl"))) {
|
||||
const raw = await fs.readFile(path.join(sessionsDir, file), "utf8").catch(() => "");
|
||||
mentions.apiCall += countOccurrences(raw, "MCP.$api");
|
||||
mentions.mcpNamespace += countOccurrences(raw, "MCP.fixture");
|
||||
mentions.mcpTool += countOccurrences(raw, "fixture__lookup_note");
|
||||
mentions.toolSearchPollution += countOccurrences(raw, 'tools.search("lookup note"');
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
|
||||
async function writeProbeMcpServer(serverPath: string) {
|
||||
const sdkMcpServerPath = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
|
||||
const sdkStdioServerPath = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
|
||||
const zodPath = require.resolve("zod");
|
||||
await fs.mkdir(path.dirname(serverPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
serverPath,
|
||||
`#!/usr/bin/env node
|
||||
import { McpServer } from ${JSON.stringify(sdkMcpServerPath)};
|
||||
import { StdioServerTransport } from ${JSON.stringify(sdkStdioServerPath)};
|
||||
import { z } from ${JSON.stringify(zodPath)};
|
||||
|
||||
const notes = new Map([
|
||||
["alpha", "fixture-note-alpha"],
|
||||
["beta", "fixture-note-beta"],
|
||||
]);
|
||||
const server = new McpServer({ name: "code-mode-fixture", version: "1.0.0" });
|
||||
|
||||
server.tool(
|
||||
"lookup_note",
|
||||
"Look up one read-only fixture note by id.",
|
||||
{
|
||||
id: z.string().describe("Fixture note id to look up."),
|
||||
},
|
||||
async ({ id }) => ({
|
||||
content: [{ type: "text", text: notes.get(id) ?? "missing-note" }],
|
||||
}),
|
||||
);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
`,
|
||||
{ encoding: "utf8", mode: 0o755 },
|
||||
);
|
||||
}
|
||||
|
||||
async function writeConfig(params: {
|
||||
configPath: string;
|
||||
stateDir: string;
|
||||
workspaceDir: string;
|
||||
gatewayPort: number;
|
||||
providerBaseUrl: string;
|
||||
serverPath: string;
|
||||
}) {
|
||||
let cfg = buildQaGatewayConfig({
|
||||
bind: "loopback",
|
||||
gatewayPort: params.gatewayPort,
|
||||
gatewayToken: "mcp-code-mode-e2e",
|
||||
providerBaseUrl: `${params.providerBaseUrl}/v1`,
|
||||
workspaceDir: params.workspaceDir,
|
||||
controlUiEnabled: false,
|
||||
providerMode: "mock-openai",
|
||||
});
|
||||
cfg = {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
slots: {
|
||||
...cfg.plugins?.slots,
|
||||
memory: "none",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
memorySearch: {
|
||||
...cfg.agents?.defaults?.memorySearch,
|
||||
enabled: false,
|
||||
sync: {
|
||||
...cfg.agents?.defaults?.memorySearch?.sync,
|
||||
onSearch: false,
|
||||
onSessionStart: false,
|
||||
watch: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
...cfg.tools,
|
||||
alsoAllow: [...new Set([...(cfg.tools?.alsoAllow ?? []), "bundle-mcp"])],
|
||||
codeMode: {
|
||||
enabled: true,
|
||||
timeoutMs: 20_000,
|
||||
maxPendingToolCalls: 16,
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
servers: {
|
||||
fixture: {
|
||||
command: "node",
|
||||
args: [params.serverPath],
|
||||
cwd: path.dirname(params.serverPath),
|
||||
connectionTimeoutMs: 30_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
...cfg.gateway,
|
||||
http: {
|
||||
endpoints: {
|
||||
responses: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
cfg = await stageQaMockAuthProfiles({
|
||||
cfg,
|
||||
stateDir: params.stateDir,
|
||||
agentIds: ["qa"],
|
||||
providers: ["mock-openai", "openai", "anthropic"],
|
||||
});
|
||||
await fs.mkdir(path.dirname(params.configPath), { recursive: true });
|
||||
await fs.writeFile(params.configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mcp-code-mode-"));
|
||||
const keep = process.env.OPENCLAW_MCP_CODE_MODE_GATEWAY_E2E_KEEP === "1";
|
||||
let provider: Awaited<ReturnType<typeof startQaMockOpenAiServer>> | undefined;
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
try {
|
||||
provider = await startQaMockOpenAiServer();
|
||||
const stateDir = path.join(rootDir, "state");
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
const serverPath = path.join(rootDir, "mcp", "fixture-server.mjs");
|
||||
const configPath = path.join(stateDir, "openclaw.json");
|
||||
const gatewayPort = await freePort();
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await writeProbeMcpServer(serverPath);
|
||||
await writeConfig({
|
||||
configPath,
|
||||
stateDir,
|
||||
workspaceDir,
|
||||
gatewayPort,
|
||||
providerBaseUrl: provider.baseUrl,
|
||||
serverPath,
|
||||
});
|
||||
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
resetConfigRuntimeState();
|
||||
|
||||
server = await startGatewayServer(gatewayPort, {
|
||||
host: "127.0.0.1",
|
||||
auth: { mode: "none" },
|
||||
controlUiEnabled: false,
|
||||
openResponsesEnabled: true,
|
||||
});
|
||||
|
||||
const beforeRequests = (await fetchJson(`${provider.baseUrl}/debug/requests`)) as unknown[];
|
||||
const response = await fetchJson(`http://127.0.0.1:${gatewayPort}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-openclaw-scopes": "operator.write",
|
||||
"x-openclaw-agent": "qa",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "openclaw/qa",
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "mcp code mode qa check: inspect the MCP typed API, call the fixture lookup_note tool for alpha, and say what was unclear.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_output_tokens: 256,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
const requests = (await fetchJson(`${provider.baseUrl}/debug/requests`)) as Array<{
|
||||
raw?: string;
|
||||
body?: { tools?: unknown[] };
|
||||
plannedToolName?: string;
|
||||
}>;
|
||||
const laneRequests = requests.slice(beforeRequests.length);
|
||||
const firstRequest = laneRequests[0] ?? {};
|
||||
const finalText = outputText(response);
|
||||
const mentions = await readSessionLogMentions(stateDir);
|
||||
const plannedTools = laneRequests
|
||||
.map((request) => request.plannedToolName)
|
||||
.filter((name): name is string => typeof name === "string");
|
||||
|
||||
assert(finalText.includes("MCP_CODE_MODE_OK"), "agent did not complete MCP code-mode turn");
|
||||
assert(plannedTools.includes("exec"), "agent did not call code-mode exec");
|
||||
assert(mentions.apiCall > 0 && mentions.mcpNamespace > 0, "session log lacks MCP API usage");
|
||||
assert(mentions.mcpTool > 0, "session log lacks materialized MCP tool call");
|
||||
assert(mentions.toolSearchPollution === 0, "MCP lookup leaked through tools.search");
|
||||
|
||||
const summary = {
|
||||
ok: true,
|
||||
rootDir,
|
||||
stateDir,
|
||||
gatewayUrl: `http://127.0.0.1:${gatewayPort}`,
|
||||
finalText,
|
||||
providerRequestCount: laneRequests.length,
|
||||
providerDeclaredToolCount: Array.isArray(firstRequest.body?.tools)
|
||||
? firstRequest.body.tools.length
|
||||
: 0,
|
||||
providerRawBytes: typeof firstRequest.raw === "string" ? firstRequest.raw.length : 0,
|
||||
plannedTools,
|
||||
sessionLogMentions: mentions,
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
|
||||
} finally {
|
||||
await server?.close({ reason: "mcp code-mode gateway e2e complete" });
|
||||
await provider?.stop();
|
||||
resetConfigRuntimeState();
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
if (!keep) {
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export type CodeModeNamespaceToolCall = {
|
||||
readonly [CODE_MODE_NAMESPACE_TOOL_CALL]: true;
|
||||
readonly toolName: string;
|
||||
readonly catalogId?: string;
|
||||
readonly local?: boolean;
|
||||
readonly input?: CodeModeNamespaceToolInputMapper;
|
||||
};
|
||||
|
||||
@@ -90,6 +91,7 @@ type CodeModeNamespaceCatalogEntry = {
|
||||
source?: string;
|
||||
name: string;
|
||||
sourceName?: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
mcp?: {
|
||||
serverName: string;
|
||||
@@ -189,6 +191,22 @@ function createCodeModeNamespaceCatalogTool(
|
||||
};
|
||||
}
|
||||
|
||||
function createCodeModeNamespaceLocalFunction(
|
||||
toolName: string,
|
||||
input: CodeModeNamespaceToolInputMapper,
|
||||
): CodeModeNamespaceToolCall {
|
||||
const normalizedToolName = toolName.trim();
|
||||
if (!normalizedToolName) {
|
||||
throw new Error("Code mode namespace local function name must be non-empty.");
|
||||
}
|
||||
return {
|
||||
[CODE_MODE_NAMESPACE_TOOL_CALL]: true,
|
||||
toolName: normalizedToolName,
|
||||
local: true,
|
||||
input,
|
||||
};
|
||||
}
|
||||
|
||||
function isCodeModeNamespaceToolCall(value: unknown): value is CodeModeNamespaceToolCall {
|
||||
const record = isRecord(value) ? (value as Record<PropertyKey, unknown>) : undefined;
|
||||
return (
|
||||
@@ -342,6 +360,12 @@ function readSchemaProperties(schema: unknown): Record<string, unknown> {
|
||||
return isRecord(record?.properties) ? record.properties : {};
|
||||
}
|
||||
|
||||
function readSchemaString(schema: unknown, key: string): string | undefined {
|
||||
const record = readSchemaRecord(schema);
|
||||
const value = record?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readRequiredKeys(schema: unknown): string[] {
|
||||
const record = readSchemaRecord(schema);
|
||||
return Array.isArray(record?.required)
|
||||
@@ -370,20 +394,15 @@ function applySchemaDefaults(
|
||||
}
|
||||
|
||||
function mapMcpNamespaceInput(schema: unknown, args: unknown[]): unknown {
|
||||
const orderedKeys = orderedSchemaKeys(schema);
|
||||
const [firstArg, ...restArgs] = args;
|
||||
const recordInput = isRecord(firstArg) && restArgs.length === 0 ? { ...firstArg } : undefined;
|
||||
const result: Record<string, unknown> = recordInput ?? {};
|
||||
const positional = recordInput ? [] : args;
|
||||
if (positional.length > orderedKeys.length) {
|
||||
throw new Error("Too many positional arguments for MCP namespace tool.");
|
||||
if (args.length > 1) {
|
||||
throw new Error("MCP namespace tools accept one object argument.");
|
||||
}
|
||||
const firstArg = args[0];
|
||||
const result: Record<string, unknown> =
|
||||
firstArg === undefined ? {} : isRecord(firstArg) ? { ...firstArg } : {};
|
||||
if (firstArg !== undefined && !isRecord(firstArg)) {
|
||||
throw new Error("MCP namespace tools accept one object argument.");
|
||||
}
|
||||
positional.forEach((value, index) => {
|
||||
const key = orderedKeys[index];
|
||||
if (key) {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
const withDefaults = applySchemaDefaults(schema, result);
|
||||
const missing = readRequiredKeys(schema).filter((key) => withDefaults[key] === undefined);
|
||||
if (missing.length > 0) {
|
||||
@@ -394,6 +413,300 @@ function mapMcpNamespaceInput(schema: unknown, args: unknown[]): unknown {
|
||||
return withDefaults;
|
||||
}
|
||||
|
||||
function escapeDocComment(value: string): string {
|
||||
return value.replace(/\*\//gu, "* /").trim();
|
||||
}
|
||||
|
||||
function indent(lines: string[], prefix: string): string[] {
|
||||
return lines.map((line) => `${prefix}${line}`);
|
||||
}
|
||||
|
||||
function renderDocComment(
|
||||
summary: string | undefined,
|
||||
params: readonly McpApiParamDoc[],
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
const docLines = normalizeDocLines(summary);
|
||||
if (docLines.length === 0 && params.length === 0) {
|
||||
return lines;
|
||||
}
|
||||
lines.push("/**");
|
||||
for (const line of docLines) {
|
||||
lines.push(` * ${escapeDocComment(line)}`);
|
||||
}
|
||||
if (docLines.length > 0 && params.length > 0) {
|
||||
lines.push(" *");
|
||||
}
|
||||
for (const param of params) {
|
||||
const description = collapseDocText(param.description);
|
||||
if (description) {
|
||||
lines.push(
|
||||
` * @param ${param.name}${param.required ? "" : "?"} ${escapeDocComment(description)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push(" */");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function normalizeDocLines(value: string | undefined): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function collapseDocText(value: string | undefined): string {
|
||||
return normalizeDocLines(value).join(" ");
|
||||
}
|
||||
|
||||
function schemaType(schema: unknown): string {
|
||||
const record = readSchemaRecord(schema);
|
||||
if (!record) {
|
||||
return "unknown";
|
||||
}
|
||||
const enumValues = Array.isArray(record.enum)
|
||||
? record.enum.filter(
|
||||
(entry): entry is string | number | boolean =>
|
||||
typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean",
|
||||
)
|
||||
: [];
|
||||
if (enumValues.length > 0 && enumValues.length <= 16) {
|
||||
return enumValues.map((entry) => JSON.stringify(entry)).join(" | ");
|
||||
}
|
||||
const oneOf = Array.isArray(record.oneOf) ? record.oneOf : undefined;
|
||||
const anyOf = Array.isArray(record.anyOf) ? record.anyOf : undefined;
|
||||
const union = oneOf ?? anyOf;
|
||||
if (union && union.length > 0 && union.length <= 8) {
|
||||
return union.map((entry) => schemaType(entry)).join(" | ");
|
||||
}
|
||||
const type = record.type;
|
||||
if (Array.isArray(type)) {
|
||||
return type.map((entry) => schemaType({ ...record, type: entry })).join(" | ");
|
||||
}
|
||||
switch (type) {
|
||||
case "string":
|
||||
return "string";
|
||||
case "integer":
|
||||
case "number":
|
||||
return "number";
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "array":
|
||||
return `${schemaType(record.items)}[]`;
|
||||
case "object":
|
||||
return renderInlineObjectType(record);
|
||||
case "null":
|
||||
return "null";
|
||||
default:
|
||||
return Object.keys(readSchemaProperties(schema)).length > 0
|
||||
? renderInlineObjectType(record)
|
||||
: "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function tsPropertyName(name: string): string {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(name) ? name : JSON.stringify(name);
|
||||
}
|
||||
|
||||
function renderInlineObjectType(schema: unknown): string {
|
||||
const properties = readSchemaProperties(schema);
|
||||
const keys = Object.keys(properties);
|
||||
if (keys.length === 0) {
|
||||
return "Record<string, unknown>";
|
||||
}
|
||||
const required = new Set(readRequiredKeys(schema));
|
||||
return `{ ${keys
|
||||
.map(
|
||||
(key) =>
|
||||
`${tsPropertyName(key)}${required.has(key) ? "" : "?"}: ${schemaType(properties[key])}`,
|
||||
)
|
||||
.join("; ")} }`;
|
||||
}
|
||||
|
||||
type McpApiParamDoc = {
|
||||
name: string;
|
||||
required: boolean;
|
||||
type: string;
|
||||
description?: string;
|
||||
defaultValue?: unknown;
|
||||
};
|
||||
|
||||
type McpApiToolDoc = {
|
||||
method: string;
|
||||
path: string[];
|
||||
mcpTool: string;
|
||||
operation: NonNullable<CodeModeNamespaceCatalogEntry["mcp"]>["operation"];
|
||||
description?: string;
|
||||
parameters: unknown;
|
||||
params: McpApiParamDoc[];
|
||||
};
|
||||
|
||||
type McpApiServerDoc = {
|
||||
identifier: string;
|
||||
serverName: string;
|
||||
tools: McpApiToolDoc[];
|
||||
};
|
||||
|
||||
function buildMcpParamDocs(schema: unknown): McpApiParamDoc[] {
|
||||
const required = new Set(readRequiredKeys(schema));
|
||||
return orderedSchemaKeys(schema).map((key) => {
|
||||
const descriptor = readSchemaProperties(schema)[key];
|
||||
const doc: McpApiParamDoc = {
|
||||
name: key,
|
||||
required: required.has(key),
|
||||
type: schemaType(descriptor),
|
||||
};
|
||||
const description = readSchemaString(descriptor, "description");
|
||||
if (description) {
|
||||
doc.description = description;
|
||||
}
|
||||
if (isRecord(descriptor) && "default" in descriptor) {
|
||||
doc.defaultValue = descriptor.default;
|
||||
}
|
||||
return doc;
|
||||
});
|
||||
}
|
||||
|
||||
function renderMcpInputType(params: readonly McpApiParamDoc[]): string[] {
|
||||
if (params.length === 0) {
|
||||
return ["input?: Record<string, never>"];
|
||||
}
|
||||
const lines = ["input: {"];
|
||||
for (const param of params) {
|
||||
if (param.description || param.defaultValue !== undefined) {
|
||||
const description = collapseDocText(param.description);
|
||||
const suffix =
|
||||
param.defaultValue === undefined ? "" : ` Default: ${JSON.stringify(param.defaultValue)}.`;
|
||||
lines.push(` /** ${escapeDocComment(`${description}${suffix}`.trim())} */`);
|
||||
}
|
||||
lines.push(` ${tsPropertyName(param.name)}${param.required ? "" : "?"}: ${param.type};`);
|
||||
}
|
||||
lines.push("}");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderMcpToolSignature(
|
||||
tool: McpApiToolDoc,
|
||||
functionName = tool.path.at(-1) ?? tool.method,
|
||||
): string[] {
|
||||
const lines = renderDocComment(tool.description, tool.params);
|
||||
lines.push(`function ${functionName}(`);
|
||||
lines.push(...indent(renderMcpInputType(tool.params), " "));
|
||||
lines.push("): Promise<McpToolResult>;");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderMcpServerHeader(server: McpApiServerDoc, tools: readonly McpApiToolDoc[]): string {
|
||||
const lines = [
|
||||
"type McpApiHeader = { header: string; tools?: unknown[]; schemas?: Record<string, unknown> };",
|
||||
"",
|
||||
"type McpToolResult = {",
|
||||
" content?: unknown[];",
|
||||
" structuredContent?: unknown;",
|
||||
" isError?: boolean;",
|
||||
" [key: string]: unknown;",
|
||||
"};",
|
||||
"",
|
||||
`declare namespace MCP.${server.identifier} {`,
|
||||
" /** Return this TypeScript-style API header. */",
|
||||
" function $api(toolName?: string, options?: { schema?: boolean }): Promise<McpApiHeader>;",
|
||||
];
|
||||
const topLevelTools = tools.filter((tool) => tool.path.length === 1);
|
||||
const nestedTools = tools.filter((tool) => tool.path.length > 1);
|
||||
for (const tool of topLevelTools) {
|
||||
lines.push("");
|
||||
lines.push(...indent(renderMcpToolSignature(tool), " "));
|
||||
}
|
||||
const nestedGroups = new Map<string, McpApiToolDoc[]>();
|
||||
for (const tool of nestedTools) {
|
||||
const groupName = tool.path[0] ?? "tools";
|
||||
nestedGroups.set(groupName, [...(nestedGroups.get(groupName) ?? []), tool]);
|
||||
}
|
||||
for (const [groupName, groupTools] of [...nestedGroups.entries()].toSorted((a, b) =>
|
||||
a[0].localeCompare(b[0]),
|
||||
)) {
|
||||
lines.push("");
|
||||
lines.push(` namespace ${groupName} {`);
|
||||
for (const tool of groupTools) {
|
||||
lines.push("");
|
||||
lines.push(...indent(renderMcpToolSignature(tool, tool.path.at(-1) ?? tool.method), " "));
|
||||
}
|
||||
lines.push(" }");
|
||||
}
|
||||
lines.push("}");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderMcpRootHeader(servers: readonly McpApiServerDoc[]): string {
|
||||
return [
|
||||
"type McpApiHeader = { header: string; servers?: unknown[] };",
|
||||
"",
|
||||
"declare const MCP: {",
|
||||
" /** List visible MCP servers and request server-specific headers. */",
|
||||
" $api(): Promise<McpApiHeader>;",
|
||||
...servers.map((server) => ` readonly ${server.identifier}: typeof MCP.${server.identifier};`),
|
||||
"};",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildMcpApiResponse(params: {
|
||||
servers: readonly McpApiServerDoc[];
|
||||
server?: McpApiServerDoc;
|
||||
args: unknown[];
|
||||
}) {
|
||||
const [selector, options] = params.args;
|
||||
const includeSchema = isRecord(options) && options.schema === true;
|
||||
if (!params.server) {
|
||||
return {
|
||||
kind: "mcp_api",
|
||||
scope: "root",
|
||||
header: renderMcpRootHeader(params.servers),
|
||||
servers: params.servers.map((server) => ({
|
||||
identifier: server.identifier,
|
||||
serverName: server.serverName,
|
||||
toolCount: server.tools.length,
|
||||
})),
|
||||
note: "Call MCP.<server>.$api() for a TypeScript-style header, then call tools with one object argument matching the shown input type.",
|
||||
};
|
||||
}
|
||||
const selected =
|
||||
typeof selector === "string" && selector.trim()
|
||||
? params.server.tools.filter(
|
||||
(tool) =>
|
||||
tool.method === selector.trim() ||
|
||||
tool.path.join(".") === selector.trim() ||
|
||||
tool.mcpTool === selector.trim(),
|
||||
)
|
||||
: params.server.tools;
|
||||
return {
|
||||
kind: "mcp_api",
|
||||
scope: selected.length === 1 ? "tool" : "server",
|
||||
server: {
|
||||
identifier: params.server.identifier,
|
||||
serverName: params.server.serverName,
|
||||
},
|
||||
header: renderMcpServerHeader(params.server, selected),
|
||||
tools: selected.map((tool) => ({
|
||||
method: tool.method,
|
||||
path: tool.path,
|
||||
mcpTool: tool.mcpTool,
|
||||
operation: tool.operation,
|
||||
description: tool.description,
|
||||
})),
|
||||
...(includeSchema
|
||||
? {
|
||||
schemas: Object.fromEntries(selected.map((tool) => [tool.method, tool.parameters])),
|
||||
}
|
||||
: {}),
|
||||
note: "Call MCP tools with one object argument, for example MCP.server.tool({ requiredField: value }).",
|
||||
};
|
||||
}
|
||||
|
||||
function scopeAtPath(
|
||||
root: CodeModeNamespaceScope,
|
||||
path: readonly string[],
|
||||
@@ -420,7 +733,7 @@ function toolIdentifiersForServer(
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created = new Set<string>(["resources", "prompts"]);
|
||||
const created = new Set<string>(["$api", "resources", "prompts"]);
|
||||
usedToolIdentifiers.set(serverIdentifier, created);
|
||||
return created;
|
||||
}
|
||||
@@ -446,6 +759,7 @@ function createMcpNamespaceScope(
|
||||
}
|
||||
const usedToolIdentifiers = new Map<string, Set<string>>();
|
||||
const root = Object.create(null) as CodeModeNamespaceScope;
|
||||
const serverDocs = new Map<string, McpApiServerDoc>();
|
||||
for (const entry of mcpEntries.toSorted((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))) {
|
||||
const mcp = entry.mcp;
|
||||
if (!mcp || !entry.id) {
|
||||
@@ -455,6 +769,11 @@ function createMcpNamespaceScope(
|
||||
serverNames.get(mcp.safeServerName) ?? uniqueIdentifier("server", usedServerIdentifiers);
|
||||
const serverScope = scopeAtPath(root, [serverIdentifier]);
|
||||
serverScope.$serverName = mcp.serverName;
|
||||
let serverDoc = serverDocs.get(serverIdentifier);
|
||||
if (!serverDoc) {
|
||||
serverDoc = { identifier: serverIdentifier, serverName: mcp.serverName, tools: [] };
|
||||
serverDocs.set(serverIdentifier, serverDoc);
|
||||
}
|
||||
const path =
|
||||
mcp.operation === "resources_list"
|
||||
? ["resources", "list"]
|
||||
@@ -476,6 +795,29 @@ function createMcpNamespaceScope(
|
||||
entry.name,
|
||||
(args) => mapMcpNamespaceInput(entry.parameters, args),
|
||||
);
|
||||
serverDoc.tools.push({
|
||||
method: path.join("."),
|
||||
path,
|
||||
mcpTool: mcp.toolName,
|
||||
operation: mcp.operation,
|
||||
description: entry.description,
|
||||
parameters: entry.parameters,
|
||||
params: buildMcpParamDocs(entry.parameters),
|
||||
});
|
||||
}
|
||||
const docs = [...serverDocs.values()].map((server) =>
|
||||
Object.assign({}, server, {
|
||||
tools: server.tools.toSorted((a, b) => a.method.localeCompare(b.method)),
|
||||
}),
|
||||
);
|
||||
root.$api = createCodeModeNamespaceLocalFunction("$api", (args) =>
|
||||
buildMcpApiResponse({ servers: docs, args }),
|
||||
);
|
||||
for (const server of docs) {
|
||||
const serverScope = scopeAtPath(root, [server.identifier]);
|
||||
serverScope.$api = createCodeModeNamespaceLocalFunction("$api", (args) =>
|
||||
buildMcpApiResponse({ servers: docs, server, args }),
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
@@ -515,13 +857,17 @@ function describeMcpNamespaceForPrompt(
|
||||
if (!scope) {
|
||||
return [];
|
||||
}
|
||||
const servers = Object.keys(scope).toSorted();
|
||||
const servers = Object.entries(scope)
|
||||
.filter(([, value]) => isRecord(value) && typeof value.$serverName === "string")
|
||||
.map(([key]) => key)
|
||||
.toSorted();
|
||||
if (servers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
"- MCP: MCP server tools grouped by server.",
|
||||
`Use MCP.<server>.<tool>(args) for MCP tools; visible servers: ${servers.join(", ")}.`,
|
||||
`Use MCP.$api() or MCP.<server>.$api() to inspect TypeScript-style API headers; visible servers: ${servers.join(", ")}.`,
|
||||
"Call MCP tools as MCP.<server>.<tool>({ ...input }) with one object argument matching the header.",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -714,10 +1060,13 @@ export async function createCodeModeNamespaceRuntime(
|
||||
if (!isCodeModeNamespaceToolCall(target)) {
|
||||
throw new Error(`Code mode namespace path is not callable: ${path.join(".")}`);
|
||||
}
|
||||
const input = target.input ? await target.input(args) : (args[0] ?? {});
|
||||
if (target.local) {
|
||||
return toJsonSafe(input);
|
||||
}
|
||||
if (!target.catalogId && !entry.registration.requiredToolNames.includes(target.toolName)) {
|
||||
throw new Error(`Code mode namespace path targets undeclared tool: ${target.toolName}`);
|
||||
}
|
||||
const input = target.input ? await target.input(args) : (args[0] ?? {});
|
||||
return toJsonSafe(
|
||||
await executeTool({
|
||||
pluginId: entry.registration.pluginId,
|
||||
|
||||
@@ -784,8 +784,8 @@ describe("Code Mode", () => {
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string" },
|
||||
repo: { type: "string" },
|
||||
title: { type: "string" },
|
||||
repo: { type: "string", description: "Repository name" },
|
||||
title: { type: "string", description: "Issue title\nShown in tracker" },
|
||||
body: { type: "string", default: "" },
|
||||
},
|
||||
required: ["owner", "repo", "title"],
|
||||
@@ -807,16 +807,42 @@ describe("Code Mode", () => {
|
||||
execTool: codeModeTools[0],
|
||||
waitTool: codeModeTools[1],
|
||||
code: `
|
||||
const created = await MCP.github.createIssue("openclaw", "openclaw", "Ship it");
|
||||
const rootApi = await MCP.$api();
|
||||
const api = await MCP.github.$api("createIssue", { schema: true });
|
||||
const created = await MCP.github.createIssue({
|
||||
owner: "openclaw",
|
||||
repo: "openclaw",
|
||||
title: "Ship it",
|
||||
});
|
||||
const createdPayload = JSON.parse(created.content[0].text);
|
||||
const searchHits = await tools.search("github create issue", { limit: 5 });
|
||||
const allHasMcp = ALL_TOOLS.some((tool) => tool.source === "mcp");
|
||||
let directCall;
|
||||
let directDescribe;
|
||||
try {
|
||||
await tools.github__create_issue({ owner: "x", repo: "y", title: "blocked" });
|
||||
await tools.describe("github__create_issue");
|
||||
directDescribe = "unexpected";
|
||||
} catch (error) {
|
||||
directDescribe = error.message;
|
||||
}
|
||||
try {
|
||||
await tools.call("github__create_issue", { owner: "x", repo: "y", title: "blocked" });
|
||||
directCall = "unexpected";
|
||||
} catch (error) {
|
||||
directCall = error.message;
|
||||
}
|
||||
return { createdPayload, createdDetails: created.details, directCall, hasMcp: "MCP" in namespaces };
|
||||
return {
|
||||
apiHeader: api.header,
|
||||
apiSchemaTitle: api.schemas.createIssue.type,
|
||||
rootServers: rootApi.servers,
|
||||
createdPayload,
|
||||
createdDetails: created.details,
|
||||
searchHits,
|
||||
allHasMcp,
|
||||
directDescribe,
|
||||
directCall,
|
||||
hasMcp: "MCP" in namespaces,
|
||||
};
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -842,9 +868,19 @@ describe("Code Mode", () => {
|
||||
body: "",
|
||||
},
|
||||
},
|
||||
directCall: "MCP tools are available in code mode only through the MCP namespace.",
|
||||
searchHits: [],
|
||||
allHasMcp: false,
|
||||
directDescribe: "Unknown tool id: github__create_issue",
|
||||
directCall: "Unknown tool id: github__create_issue",
|
||||
hasMcp: true,
|
||||
apiSchemaTitle: "object",
|
||||
apiHeader: expect.stringContaining("function createIssue("),
|
||||
rootServers: [{ identifier: "github", serverName: "github", toolCount: 1 }],
|
||||
});
|
||||
const value = details.value as { apiHeader: string };
|
||||
expect(value.apiHeader).toContain("@param title Issue title Shown in tracker");
|
||||
expect(value.apiHeader).not.toContain("@param title Issue title\n");
|
||||
expect(value.apiHeader).toContain("title: string;");
|
||||
expect(githubCreate.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -888,9 +924,10 @@ describe("Code Mode", () => {
|
||||
execTool: codeModeTools[0],
|
||||
waitTool: codeModeTools[1],
|
||||
code: `
|
||||
const resource = await MCP.docs.resources.read("memo://one");
|
||||
const prompt = await MCP.docs.prompts.get("brief", { topic: "mcp" });
|
||||
return { resource: resource.details, prompt: prompt.details };
|
||||
const api = await MCP.docs.$api();
|
||||
const resource = await MCP.docs.resources.read({ uri: "memo://one" });
|
||||
const prompt = await MCP.docs.prompts.get({ name: "brief", arguments: { topic: "mcp" } });
|
||||
return { header: api.header, resource: resource.details, prompt: prompt.details };
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -906,6 +943,7 @@ describe("Code Mode", () => {
|
||||
toolName: "prompts_get",
|
||||
input: { name: "brief", arguments: { topic: "mcp" } },
|
||||
},
|
||||
header: expect.stringContaining("namespace resources"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -933,7 +971,7 @@ describe("Code Mode", () => {
|
||||
const details = await runUntilCompleted({
|
||||
execTool: codeModeTools[0],
|
||||
waitTool: codeModeTools[1],
|
||||
code: 'return (await MCP.constructor2.prototype2("safe")).details;',
|
||||
code: 'return (await MCP.constructor2.prototype2({ value: "safe" })).details;',
|
||||
});
|
||||
|
||||
expect(details.status).toBe("completed");
|
||||
@@ -1642,6 +1680,58 @@ describe("Code Mode", () => {
|
||||
expect(testing.activeRuns.size).toBe(beforeRunCount);
|
||||
});
|
||||
|
||||
it("enforces output limits before auto-draining namespace calls", async () => {
|
||||
registerTestNamespace({
|
||||
id: "tickets",
|
||||
pluginId: "fake-code-mode",
|
||||
globalName: "Tickets",
|
||||
requiredToolNames: ["fake_list_issues"],
|
||||
createScope: () => ({
|
||||
list: createCodeModeNamespaceTool("fake_list_issues", ([input]) => input),
|
||||
}),
|
||||
});
|
||||
const catalogRef = createToolSearchCatalogRef();
|
||||
const config = {
|
||||
tools: {
|
||||
codeMode: {
|
||||
enabled: true,
|
||||
maxOutputBytes: 1024,
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
const ctx = {
|
||||
config,
|
||||
runtimeConfig: config,
|
||||
sessionId: "session-code-mode",
|
||||
sessionKey: "agent:main:main",
|
||||
runId: "run-code-mode",
|
||||
catalogRef,
|
||||
};
|
||||
const tools = createCodeModeTools(ctx);
|
||||
const listIssues = pluginToolWithExecute("fake_list_issues", "List issues", async () =>
|
||||
jsonResult({ ok: true }),
|
||||
);
|
||||
applyCodeModeCatalog({
|
||||
tools: [...tools, listIssues],
|
||||
config,
|
||||
sessionId: "session-code-mode",
|
||||
sessionKey: "agent:main:main",
|
||||
runId: "run-code-mode",
|
||||
catalogRef,
|
||||
});
|
||||
|
||||
const details = resultDetails(
|
||||
await tools[0].execute("code-call-large-namespace", {
|
||||
code: 'text("x".repeat(2048)); await Tickets.list({ state: "open" }); return 1;',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(details.status).toBe("failed");
|
||||
expect(String(details.error)).toContain("output limit exceeded");
|
||||
expect(details.code).toBe("output_limit_exceeded");
|
||||
expect(listIssues.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves guest output when a run fails", async () => {
|
||||
const { config, catalogRef, tools } = createCodeModeHarness();
|
||||
applyCodeModeCatalog({
|
||||
|
||||
@@ -143,6 +143,7 @@ type CodeModeWorkerResult =
|
||||
|
||||
const activeRuns = new Map<string, CodeModeRunState>();
|
||||
const resumingRunIds = new Set<string>();
|
||||
let activeRunReservations = 0;
|
||||
let typescriptRuntimePromise: Promise<typeof import("typescript")> | null = null;
|
||||
let typescriptRuntimeForTest: typeof import("typescript") | null = null;
|
||||
|
||||
@@ -261,11 +262,24 @@ function resolveCodeModeSnapshotExpiresAt(now: number, ttlSeconds: number): numb
|
||||
|
||||
function enforceActiveRunLimit(): void {
|
||||
removeExpiredRuns();
|
||||
if (activeRuns.size >= MAX_ACTIVE_CODE_MODE_RUNS) {
|
||||
if (activeRuns.size + activeRunReservations >= MAX_ACTIVE_CODE_MODE_RUNS) {
|
||||
throw new ToolInputError("too many suspended code mode runs.");
|
||||
}
|
||||
}
|
||||
|
||||
function reserveActiveRunSlot(): () => void {
|
||||
enforceActiveRunLimit();
|
||||
activeRunReservations += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
activeRunReservations = Math.max(0, activeRunReservations - 1);
|
||||
};
|
||||
}
|
||||
|
||||
function toJsonSafe(value: unknown): unknown {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
@@ -501,6 +515,7 @@ async function runBridgeRequest(params: {
|
||||
const options = isRecord(values[1]) ? values[1] : undefined;
|
||||
value = await params.runtime.search(query, {
|
||||
limit: typeof options?.limit === "number" ? options.limit : undefined,
|
||||
includeMcp: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -509,7 +524,7 @@ async function runBridgeRequest(params: {
|
||||
if (typeof id !== "string") {
|
||||
throw new ToolInputError("describe id must be a string.");
|
||||
}
|
||||
value = await params.runtime.describe(id);
|
||||
value = await params.runtime.describe(id, { includeMcp: false });
|
||||
break;
|
||||
}
|
||||
case "call": {
|
||||
@@ -517,12 +532,7 @@ async function runBridgeRequest(params: {
|
||||
if (typeof id !== "string") {
|
||||
throw new ToolInputError("call id must be a string.");
|
||||
}
|
||||
const described = await params.runtime.describe(id);
|
||||
if (described.source === "mcp") {
|
||||
throw new ToolInputError(
|
||||
"MCP tools are available in code mode only through the MCP namespace.",
|
||||
);
|
||||
}
|
||||
const described = await params.runtime.describe(id, { includeMcp: false });
|
||||
value = await params.runtime.callExactId(described.id, values[1] ?? {}, {
|
||||
parentToolCallId: params.parentToolCallId,
|
||||
signal: params.signal,
|
||||
@@ -710,14 +720,43 @@ function snapshotState(params: {
|
||||
output: unknown[];
|
||||
signal?: AbortSignal;
|
||||
onUpdate?: AgentToolUpdateCallback;
|
||||
}) {
|
||||
enforceSnapshotStateLimits(params);
|
||||
return storeSnapshotState({
|
||||
...params,
|
||||
pending: createPendingBridgeStates(params),
|
||||
});
|
||||
}
|
||||
|
||||
function enforceSnapshotStateLimits(params: {
|
||||
snapshotBytes: Uint8Array;
|
||||
config: CodeModeConfig;
|
||||
output: unknown[];
|
||||
}) {
|
||||
enforceActiveRunLimit();
|
||||
enforceSnapshotPayloadLimits(params);
|
||||
}
|
||||
|
||||
function enforceSnapshotPayloadLimits(params: {
|
||||
snapshotBytes: Uint8Array;
|
||||
config: CodeModeConfig;
|
||||
output: unknown[];
|
||||
}) {
|
||||
if (params.snapshotBytes.byteLength > params.config.maxSnapshotBytes) {
|
||||
throw new CodeModeLimitError("snapshot_limit_exceeded", "code mode snapshot limit exceeded");
|
||||
}
|
||||
enforceOutputLimit(params.output, params.config);
|
||||
const runId = `cm_${randomUUID()}`;
|
||||
const pending = params.pendingRequests.map((request) => {
|
||||
}
|
||||
|
||||
function createPendingBridgeStates(params: {
|
||||
pendingRequests: PendingBridgeRequest[];
|
||||
runtime: ToolSearchRuntime;
|
||||
namespaceRuntime: CodeModeNamespaceRuntime;
|
||||
parentToolCallId: string;
|
||||
signal?: AbortSignal;
|
||||
onUpdate?: AgentToolUpdateCallback;
|
||||
}): PendingBridgeState[] {
|
||||
return params.pendingRequests.map((request) => {
|
||||
const promise = runBridgeRequest({
|
||||
runtime: params.runtime,
|
||||
namespaceRuntime: params.namespaceRuntime,
|
||||
@@ -732,6 +771,19 @@ function snapshotState(params: {
|
||||
});
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
function storeSnapshotState(params: {
|
||||
pending: PendingBridgeState[];
|
||||
snapshotBytes: Uint8Array;
|
||||
parentToolCallId: string;
|
||||
ctx: ToolSearchToolContext;
|
||||
config: CodeModeConfig;
|
||||
runtime: ToolSearchRuntime;
|
||||
namespaceRuntime: CodeModeNamespaceRuntime;
|
||||
output: unknown[];
|
||||
}) {
|
||||
const runId = `cm_${randomUUID()}`;
|
||||
const now = Date.now();
|
||||
const expiresAt = resolveCodeModeSnapshotExpiresAt(now, params.config.snapshotTtlSeconds);
|
||||
if (expiresAt === undefined) {
|
||||
@@ -743,7 +795,7 @@ function snapshotState(params: {
|
||||
ctx: params.ctx,
|
||||
config: params.config,
|
||||
snapshotBytes: params.snapshotBytes,
|
||||
pending,
|
||||
pending: params.pending,
|
||||
output: params.output,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
@@ -753,8 +805,8 @@ function snapshotState(params: {
|
||||
return {
|
||||
status: "waiting" as const,
|
||||
runId,
|
||||
reason: codeModeWaitingReason(pending),
|
||||
pendingToolCalls: pendingToolCalls(pending),
|
||||
reason: codeModeWaitingReason(params.pending),
|
||||
pendingToolCalls: pendingToolCalls(params.pending),
|
||||
output: params.output,
|
||||
telemetry: telemetry(params.runtime),
|
||||
};
|
||||
@@ -805,7 +857,7 @@ async function runExec(params: {
|
||||
throw new ToolInputError("code mode is disabled.");
|
||||
}
|
||||
const runtime = new ToolSearchRuntime(params.ctx, toToolSearchConfig(config));
|
||||
const catalog = runtime.all();
|
||||
const catalog = runtime.all({ includeMcp: false });
|
||||
const namespaceRuntime = await createCodeModeNamespaceRuntime(
|
||||
params.ctx,
|
||||
runtime.namespaceEntries(),
|
||||
@@ -835,29 +887,17 @@ async function runExec(params: {
|
||||
config.timeoutMs + 1000,
|
||||
),
|
||||
);
|
||||
if (result.status === "waiting") {
|
||||
return snapshotState({
|
||||
pendingRequests: result.pendingRequests,
|
||||
snapshotBytes: result.snapshotBytes,
|
||||
parentToolCallId: params.toolCallId,
|
||||
ctx: params.ctx,
|
||||
config,
|
||||
runtime,
|
||||
namespaceRuntime,
|
||||
output: result.output,
|
||||
signal: params.signal,
|
||||
onUpdate: params.onUpdate,
|
||||
});
|
||||
}
|
||||
enforceResultLimit({
|
||||
return await settleCodeModeResult({
|
||||
result,
|
||||
output: result.output,
|
||||
value: result.status === "completed" ? result.value : undefined,
|
||||
parentToolCallId: params.toolCallId,
|
||||
ctx: params.ctx,
|
||||
config,
|
||||
runtime,
|
||||
namespaceRuntime,
|
||||
signal: params.signal,
|
||||
onUpdate: params.onUpdate,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
telemetry: telemetry(runtime),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed" as const,
|
||||
@@ -889,6 +929,107 @@ async function waitForPending(pending: PendingBridgeState[], timeoutMs: number):
|
||||
}
|
||||
}
|
||||
|
||||
async function settleCodeModeResult(params: {
|
||||
result: CodeModeWorkerResult;
|
||||
output: unknown[];
|
||||
parentToolCallId: string;
|
||||
ctx: ToolSearchToolContext;
|
||||
config: CodeModeConfig;
|
||||
runtime: ToolSearchRuntime;
|
||||
namespaceRuntime: CodeModeNamespaceRuntime;
|
||||
signal?: AbortSignal;
|
||||
onUpdate?: AgentToolUpdateCallback;
|
||||
}) {
|
||||
let result = params.result;
|
||||
const output = params.output;
|
||||
let namespaceRounds = 0;
|
||||
const settleDeadline = Date.now() + params.config.timeoutMs;
|
||||
while (
|
||||
result.status === "waiting" &&
|
||||
result.pendingRequests.length > 0 &&
|
||||
result.pendingRequests.every((request) => request.method === "namespace") &&
|
||||
namespaceRounds < params.config.maxPendingToolCalls
|
||||
) {
|
||||
const remainingMs = settleDeadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
enforceSnapshotPayloadLimits({
|
||||
snapshotBytes: result.snapshotBytes,
|
||||
config: params.config,
|
||||
output,
|
||||
});
|
||||
const releaseReservation = reserveActiveRunSlot();
|
||||
try {
|
||||
const pending = createPendingBridgeStates({
|
||||
pendingRequests: result.pendingRequests,
|
||||
runtime: params.runtime,
|
||||
namespaceRuntime: params.namespaceRuntime,
|
||||
parentToolCallId: params.parentToolCallId,
|
||||
signal: params.signal,
|
||||
onUpdate: params.onUpdate,
|
||||
});
|
||||
const ready = await waitForPending(pending, remainingMs);
|
||||
if (!ready) {
|
||||
return storeSnapshotState({
|
||||
pending,
|
||||
snapshotBytes: result.snapshotBytes,
|
||||
parentToolCallId: params.parentToolCallId,
|
||||
ctx: params.ctx,
|
||||
config: params.config,
|
||||
runtime: params.runtime,
|
||||
namespaceRuntime: params.namespaceRuntime,
|
||||
output,
|
||||
});
|
||||
}
|
||||
const settledRequests: SettledBridgeRequest[] = [];
|
||||
for (const entry of pending) {
|
||||
settledRequests.push(entry.settled ?? (await entry.promise));
|
||||
}
|
||||
result = normalizeCodeModeWorkerResult(
|
||||
await runCodeModeWorker(
|
||||
{
|
||||
kind: "resume",
|
||||
snapshotBytes: result.snapshotBytes,
|
||||
config: params.config,
|
||||
settledRequests,
|
||||
},
|
||||
Math.max(1, settleDeadline - Date.now()) + 1000,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
releaseReservation();
|
||||
}
|
||||
output.push(...result.output);
|
||||
enforceOutputLimit(output, params.config);
|
||||
namespaceRounds += 1;
|
||||
}
|
||||
if (result.status === "waiting") {
|
||||
return snapshotState({
|
||||
pendingRequests: result.pendingRequests,
|
||||
snapshotBytes: result.snapshotBytes,
|
||||
parentToolCallId: params.parentToolCallId,
|
||||
ctx: params.ctx,
|
||||
config: params.config,
|
||||
runtime: params.runtime,
|
||||
namespaceRuntime: params.namespaceRuntime,
|
||||
output,
|
||||
signal: params.signal,
|
||||
onUpdate: params.onUpdate,
|
||||
});
|
||||
}
|
||||
enforceResultLimit({
|
||||
output,
|
||||
value: result.status === "completed" ? result.value : undefined,
|
||||
config: params.config,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
output,
|
||||
telemetry: telemetry(params.runtime),
|
||||
};
|
||||
}
|
||||
|
||||
async function runWait(params: {
|
||||
toolCallId: string;
|
||||
ctx: CodeModeToolContext;
|
||||
@@ -949,30 +1090,17 @@ async function runWait(params: {
|
||||
);
|
||||
const output = [...state.output, ...result.output];
|
||||
enforceOutputLimit(output, state.config);
|
||||
if (result.status === "waiting") {
|
||||
return snapshotState({
|
||||
pendingRequests: result.pendingRequests,
|
||||
snapshotBytes: result.snapshotBytes,
|
||||
parentToolCallId: params.toolCallId,
|
||||
ctx: state.ctx,
|
||||
config: state.config,
|
||||
runtime: state.runtime,
|
||||
namespaceRuntime: state.namespaceRuntime,
|
||||
output,
|
||||
signal: params.signal,
|
||||
onUpdate: params.onUpdate,
|
||||
});
|
||||
}
|
||||
enforceResultLimit({
|
||||
return await settleCodeModeResult({
|
||||
result,
|
||||
output,
|
||||
value: result.status === "completed" ? result.value : undefined,
|
||||
parentToolCallId: params.toolCallId,
|
||||
ctx: state.ctx,
|
||||
config: state.config,
|
||||
runtime: state.runtime,
|
||||
namespaceRuntime: state.namespaceRuntime,
|
||||
signal: params.signal,
|
||||
onUpdate: params.onUpdate,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
output,
|
||||
telemetry: telemetry(state.runtime),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed" as const,
|
||||
|
||||
@@ -39,6 +39,9 @@ const MAX_REUSABLE_CATALOG_SNAPSHOTS = 256;
|
||||
type ToolSearchMode = "code" | "tools";
|
||||
type CatalogSource = "openclaw" | "mcp" | "client";
|
||||
type CatalogTool = AnyAgentTool | ToolDefinition;
|
||||
type CatalogVisibilityOptions = {
|
||||
includeMcp?: boolean;
|
||||
};
|
||||
|
||||
type ReusableCatalogSnapshot = {
|
||||
entries: ToolSearchCatalogEntry[];
|
||||
@@ -1012,9 +1015,22 @@ function scoreEntry(entry: ToolSearchCatalogEntry, terms: string[]): number {
|
||||
return score;
|
||||
}
|
||||
|
||||
function findEntry(catalog: ToolSearchCatalogSession, id: string): ToolSearchCatalogEntry {
|
||||
function visibleCatalogEntries(
|
||||
catalog: ToolSearchCatalogSession,
|
||||
options?: CatalogVisibilityOptions,
|
||||
): ToolSearchCatalogEntry[] {
|
||||
return options?.includeMcp === false
|
||||
? catalog.entries.filter((entry) => entry.source !== "mcp")
|
||||
: catalog.entries;
|
||||
}
|
||||
|
||||
function findEntry(
|
||||
catalog: ToolSearchCatalogSession,
|
||||
id: string,
|
||||
options?: CatalogVisibilityOptions,
|
||||
): ToolSearchCatalogEntry {
|
||||
const needle = id.trim();
|
||||
const entry = catalog.entries.find(
|
||||
const entry = visibleCatalogEntries(catalog, options).find(
|
||||
(candidate) => candidate.id === needle || candidate.name === needle,
|
||||
);
|
||||
if (!entry) {
|
||||
@@ -1105,12 +1121,12 @@ export class ToolSearchRuntime {
|
||||
private readonly config: ToolSearchConfig,
|
||||
) {}
|
||||
|
||||
search = async (query: string, options?: { limit?: number }) => {
|
||||
search = async (query: string, options?: { limit?: number } & CatalogVisibilityOptions) => {
|
||||
const catalog = resolveCatalog(this.ctx);
|
||||
catalog.searchCount += 1;
|
||||
const limit = readLimit(options?.limit, this.config);
|
||||
const terms = tokenize(query);
|
||||
return catalog.entries
|
||||
return visibleCatalogEntries(catalog, options)
|
||||
.map((entry) => ({ entry, score: scoreEntry(entry, terms) }))
|
||||
.filter((hit) => hit.score > 0)
|
||||
.toSorted((a, b) => b.score - a.score || a.entry.id.localeCompare(b.entry.id))
|
||||
@@ -1118,9 +1134,9 @@ export class ToolSearchRuntime {
|
||||
.map((hit) => compactEntry(hit.entry));
|
||||
};
|
||||
|
||||
all = () => {
|
||||
all = (options?: CatalogVisibilityOptions) => {
|
||||
const catalog = resolveCatalog(this.ctx);
|
||||
return catalog.entries.map((entry) => compactEntry(entry));
|
||||
return visibleCatalogEntries(catalog, options).map((entry) => compactEntry(entry));
|
||||
};
|
||||
|
||||
namespaceEntries = () => {
|
||||
@@ -1132,10 +1148,10 @@ export class ToolSearchRuntime {
|
||||
);
|
||||
};
|
||||
|
||||
describe = async (id: string) => {
|
||||
describe = async (id: string, options?: CatalogVisibilityOptions) => {
|
||||
const catalog = resolveCatalog(this.ctx);
|
||||
catalog.describeCount += 1;
|
||||
return describeEntry(findEntry(catalog, id));
|
||||
return describeEntry(findEntry(catalog, id, options));
|
||||
};
|
||||
|
||||
call = async (
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
} from "../../vite.config.ts";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
type ResolveIdHandler = (
|
||||
this: never,
|
||||
source: string,
|
||||
importer: string | undefined,
|
||||
options: { custom: Record<string, never>; isEntry: boolean; ssr: boolean },
|
||||
) => unknown;
|
||||
|
||||
function findStringAlias(key: string) {
|
||||
return resolveTsconfigPathAliasesForVite().find((alias) => alias.find === key);
|
||||
@@ -55,16 +61,18 @@ describe("Control UI Vite config", () => {
|
||||
it("uses a browser-safe redactor for shared tool display imports", async () => {
|
||||
const plugin = controlUiBrowserOnlySharedModuleAliases();
|
||||
const resolveIdHook = plugin.resolveId;
|
||||
const resolveId = typeof resolveIdHook === "function" ? resolveIdHook : resolveIdHook?.handler;
|
||||
if (typeof resolveId !== "function") {
|
||||
const resolveIdHandler = (
|
||||
typeof resolveIdHook === "function" ? resolveIdHook : resolveIdHook?.handler
|
||||
) as ResolveIdHandler | undefined;
|
||||
if (!resolveIdHandler) {
|
||||
throw new Error("Expected browser-only shared module alias plugin to expose resolveId");
|
||||
}
|
||||
|
||||
const resolved = await resolveId.call(
|
||||
const resolved = await resolveIdHandler.call(
|
||||
{} as never,
|
||||
"../logging/redact.js",
|
||||
path.join(repoRoot, "src/agents/tool-display-common.ts"),
|
||||
{ attributes: {}, custom: {}, isEntry: false, ssr: false },
|
||||
{ custom: {}, isEntry: false, ssr: false },
|
||||
);
|
||||
|
||||
expect(resolved).toBe(path.join(repoRoot, "ui/src/ui/browser-redact.ts"));
|
||||
|
||||
Reference in New Issue
Block a user