fix(qa-lab): use truncateUtf16Safe for tool search gateway and mock OpenAI text truncation (#103219)

* fix(qa-lab): use truncateUtf16Safe for tool search and mock OpenAI text truncation

Replace naive .slice(0, N) with truncateUtf16Safe() in:
- tool-search-gateway.fixture.ts: 4 sites (provider input snippet,
  tool output snippet, gateway output text for normal/code lanes)
- mock-openai/server.ts: 1 site (tool output evidence snippet)

LLM output text may contain non-BMP characters (emoji, CJK
extension characters). Naive .slice(0, N) at a surrogate pair
boundary produces a lone surrogate.

Co-Authored-By: Claude <noreply@anthropic.com>

* test: add proof script for qa-lab UTF-16 safe truncation at all boundaries

* test(qa-lab): integrate UTF-16 boundary coverage

Co-authored-by: lsr911 <liao.shirong@xydigit.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
lsr911
2026-07-11 06:26:42 +08:00
committed by GitHub
parent b0e3c85a61
commit 9dee9ebffa
4 changed files with 135 additions and 7 deletions

View File

@@ -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 =

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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,
},
},