diff --git a/extensions/qa-lab/src/providers/mock-openai/server.test.ts b/extensions/qa-lab/src/providers/mock-openai/server.test.ts index f43f7850a9e4..d5bea81d993a 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.test.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.test.ts @@ -831,6 +831,29 @@ describe("qa mock openai server", () => { expect(text).not.toContain("HEARTBEAT_OK"); }); + it("preserves surrogate pairs in HTTP tool-output evidence snippets", async () => { + const server = await startMockServer(); + const safePrefix = "x".repeat(219); + + const final = await expectResponsesJson<{ + output: Array<{ content?: Array<{ text?: string }> }>; + }>(server, { + stream: false, + input: [ + makeUserInput("Summarize the tool result."), + { + type: "function_call_output", + call_id: "call_mock_read_1", + output: `${safePrefix}😀tail`, + }, + ], + }); + + expect(final.output[0]?.content?.[0]?.text).toBe( + `Protocol note: I reviewed the requested material. Evidence snippet: ${safePrefix}`, + ); + }); + it("requires deterministic tool-progress error prompts to observe a failed tool", async () => { const server = await startMockServer(); const prompt = diff --git a/extensions/qa-lab/src/providers/mock-openai/server.ts b/extensions/qa-lab/src/providers/mock-openai/server.ts index 1a608203da66..2d254fa58178 100644 --- a/extensions/qa-lab/src/providers/mock-openai/server.ts +++ b/extensions/qa-lab/src/providers/mock-openai/server.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { setTimeout as sleep } from "node:timers/promises"; -import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime"; +import { escapeRegExp, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-ingress"; import { closeQaHttpServer } from "../../bus-server.js"; import { QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY } from "../../qa-web-search-provider.js"; @@ -1710,7 +1710,7 @@ function buildAssistantText( ].join("\n"); } if (toolOutput) { - const snippet = toolOutput.replace(/\s+/g, " ").trim().slice(0, 220); + const snippet = truncateUtf16Safe(toolOutput.replace(/\s+/g, " ").trim(), 220); return `Protocol note: I reviewed the requested material. Evidence snippet: ${snippet || "no content"}`; } if (finishExactlyDirective) { diff --git a/extensions/qa-lab/src/tool-search-gateway.fixture.test.ts b/extensions/qa-lab/src/tool-search-gateway.fixture.test.ts index ddcee989ebb7..a224aa332e11 100644 --- a/extensions/qa-lab/src/tool-search-gateway.fixture.test.ts +++ b/extensions/qa-lab/src/tool-search-gateway.fixture.test.ts @@ -2,19 +2,25 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { countSessionLogMentions, countSystemPromptChars, outputText, outputToolNames, } from "./fixture-utils.js"; +import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js"; import { assertToolSearchLaneResults, fetchJson, readToolSearchGatewayFetchLimits, + runToolSearchGatewayLane, } from "./tool-search-gateway.fixture.js"; +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe("tool search gateway e2e fetch helper", () => { it("rejects loose numeric env limits instead of parsing prefixes", () => { expect(() => @@ -140,6 +146,74 @@ describe("tool search gateway e2e session log scanner", () => { }); }); +describe("tool search gateway e2e lane result", () => { + it("preserves surrogate pairs in provider request snippets", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tool-search-lane-")); + const configPath = path.join(tempRoot, "openclaw.json"); + const inputPrefix = "i".repeat(499); + const toolOutputPrefix = "o".repeat(3_999); + await fs.writeFile(configPath, "{}\n", "utf8"); + const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse([])) + .mockResolvedValueOnce(jsonResponse({ output: [], status: "completed" })) + .mockResolvedValueOnce( + jsonResponse([ + { + allInputText: `${inputPrefix}😀tail`, + body: { tools: [] }, + plannedToolName: "fake_plugin_tool_17", + raw: "{}", + toolOutput: `${toolOutputPrefix}😀tail`, + }, + ]), + ); + vi.stubGlobal("fetch", fetchMock); + const env: QaSuiteRuntimeEnv = { + alternateModel: "openai/gpt-5.5", + cfg: {}, + gateway: { + baseUrl: "http://gateway.test", + call: async () => undefined, + restartAfterStateMutation: async (mutateState) => { + await mutateState({ + configPath, + runtimeEnv: {}, + stateDir: path.join(tempRoot, "state"), + tempRoot, + }); + }, + runtimeEnv: { OPENCLAW_GATEWAY_TOKEN: "test-token" }, + tempRoot, + workspaceDir: tempRoot, + }, + mock: { baseUrl: "http://mock-openai.test" }, + primaryModel: "openai/gpt-5.5", + providerMode: "mock-openai", + repoRoot: tempRoot, + transport: {} as QaSuiteRuntimeEnv["transport"], + }; + + try { + const result = await runToolSearchGatewayLane({ + env, + fixture: { fakePluginDir: tempRoot, targetTool: "fake_plugin_tool_17" }, + lane: "normal", + }); + + expect(result.providerInputSnippet).toBe(inputPrefix); + expect(result.providerToolOutputSnippet).toBe(toolOutputPrefix); + expect(fetchMock).toHaveBeenCalledTimes(3); + } finally { + await fs.rm(tempRoot, { force: true, recursive: true }); + } + }); +}); + describe("qa fixture response helpers", () => { it("reads Responses API text, function call names, and prompt sizing", () => { const payload = { @@ -198,6 +272,33 @@ describe("tool search gateway e2e lane assertions", () => { ).not.toThrow(); }); + it("preserves surrogate pairs in both lane debug output snippets", () => { + const outputPrefix = `FAKE_PLUGIN_OK ${targetTool} `; + const normalOutput = `${outputPrefix}${"n".repeat(299 - outputPrefix.length)}`; + const codeOutput = `${outputPrefix}${"c".repeat(299 - outputPrefix.length)}`; + const assertInvalidLaneResults = () => + assertToolSearchLaneResults({ + targetTool, + normal: { + ...normal, + gatewayOutputText: `${normalOutput}😀tail`, + }, + code: { + gatewayOutputText: `${codeOutput}😀tail`, + providerDeclaredToolCount: 1, + providerPlannedTools: ["tool_search_code", targetTool], + providerRawBytes: 4_000, + sessionLogToolMentions: { + tool_search_code: 1, + [targetTool]: 1, + }, + }, + }); + + expect(assertInvalidLaneResults).toThrow(`"output": "${normalOutput}"`); + expect(assertInvalidLaneResults).toThrow(`"output": "${codeOutput}"`); + }); + it("rejects code lane output that only echoes the target tool name", () => { expect(() => assertToolSearchLaneResults({ diff --git a/extensions/qa-lab/src/tool-search-gateway.fixture.ts b/extensions/qa-lab/src/tool-search-gateway.fixture.ts index 55a0cac2ec7f..558184b85ad3 100644 --- a/extensions/qa-lab/src/tool-search-gateway.fixture.ts +++ b/extensions/qa-lab/src/tool-search-gateway.fixture.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { countSessionLogMentions, countSystemPromptChars, @@ -424,8 +425,11 @@ export async function runToolSearchGatewayLane(params: { providerRequestCount: laneRequests.length, providerRawBytes: typeof lastRequest.raw === "string" ? lastRequest.raw.length : 0, providerSystemPromptChars: countSystemPromptChars(lastRequest.body), - providerInputSnippet: (lastRequest.allInputText ?? lastRequest.prompt ?? "").slice(0, 500), - providerToolOutputSnippet: (lastRequest.toolOutput ?? "").slice(0, 4_000), + providerInputSnippet: truncateUtf16Safe( + lastRequest.allInputText ?? lastRequest.prompt ?? "", + 500, + ), + providerToolOutputSnippet: truncateUtf16Safe(lastRequest.toolOutput ?? "", 4_000), providerDeclaredToolCount: Array.isArray(lastRequest.body?.tools) ? lastRequest.body.tools.length : 0, @@ -452,7 +456,7 @@ export function assertToolSearchLaneResults(params: { declaredToolCount: normal.providerDeclaredToolCount, input: normal.providerInputSnippet, toolOutput: normal.providerToolOutputSnippet, - output: normal.gatewayOutputText.slice(0, 300), + output: truncateUtf16Safe(normal.gatewayOutputText, 300), mentions: normal.sessionLogToolMentions, }, code: { @@ -460,7 +464,7 @@ export function assertToolSearchLaneResults(params: { declaredToolCount: code.providerDeclaredToolCount, input: code.providerInputSnippet, toolOutput: code.providerToolOutputSnippet, - output: code.gatewayOutputText.slice(0, 300), + output: truncateUtf16Safe(code.gatewayOutputText, 300), mentions: code.sessionLogToolMentions, }, },