Skip to content

Commit 9dee9eb

Browse files
lsr911claudesteipete
authored
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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent b0e3c85 commit 9dee9eb

4 files changed

Lines changed: 135 additions & 7 deletions

File tree

extensions/qa-lab/src/providers/mock-openai/server.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,29 @@ describe("qa mock openai server", () => {
831831
expect(text).not.toContain("HEARTBEAT_OK");
832832
});
833833

834+
it("preserves surrogate pairs in HTTP tool-output evidence snippets", async () => {
835+
const server = await startMockServer();
836+
const safePrefix = "x".repeat(219);
837+
838+
const final = await expectResponsesJson<{
839+
output: Array<{ content?: Array<{ text?: string }> }>;
840+
}>(server, {
841+
stream: false,
842+
input: [
843+
makeUserInput("Summarize the tool result."),
844+
{
845+
type: "function_call_output",
846+
call_id: "call_mock_read_1",
847+
output: `${safePrefix}😀tail`,
848+
},
849+
],
850+
});
851+
852+
expect(final.output[0]?.content?.[0]?.text).toBe(
853+
`Protocol note: I reviewed the requested material. Evidence snippet: ${safePrefix}`,
854+
);
855+
});
856+
834857
it("requires deterministic tool-progress error prompts to observe a failed tool", async () => {
835858
const server = await startMockServer();
836859
const prompt =

extensions/qa-lab/src/providers/mock-openai/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { createHash } from "node:crypto";
33
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
44
import { setTimeout as sleep } from "node:timers/promises";
5-
import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
5+
import { escapeRegExp, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
66
import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-ingress";
77
import { closeQaHttpServer } from "../../bus-server.js";
88
import { QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY } from "../../qa-web-search-provider.js";
@@ -1710,7 +1710,7 @@ function buildAssistantText(
17101710
].join("\n");
17111711
}
17121712
if (toolOutput) {
1713-
const snippet = toolOutput.replace(/\s+/g, " ").trim().slice(0, 220);
1713+
const snippet = truncateUtf16Safe(toolOutput.replace(/\s+/g, " ").trim(), 220);
17141714
return `Protocol note: I reviewed the requested material. Evidence snippet: ${snippet || "no content"}`;
17151715
}
17161716
if (finishExactlyDirective) {

extensions/qa-lab/src/tool-search-gateway.fixture.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,25 @@
22
import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
5-
import { describe, expect, it } from "vitest";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
66
import {
77
countSessionLogMentions,
88
countSystemPromptChars,
99
outputText,
1010
outputToolNames,
1111
} from "./fixture-utils.js";
12+
import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
1213
import {
1314
assertToolSearchLaneResults,
1415
fetchJson,
1516
readToolSearchGatewayFetchLimits,
17+
runToolSearchGatewayLane,
1618
} from "./tool-search-gateway.fixture.js";
1719

20+
afterEach(() => {
21+
vi.unstubAllGlobals();
22+
});
23+
1824
describe("tool search gateway e2e fetch helper", () => {
1925
it("rejects loose numeric env limits instead of parsing prefixes", () => {
2026
expect(() =>
@@ -140,6 +146,74 @@ describe("tool search gateway e2e session log scanner", () => {
140146
});
141147
});
142148

149+
describe("tool search gateway e2e lane result", () => {
150+
it("preserves surrogate pairs in provider request snippets", async () => {
151+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tool-search-lane-"));
152+
const configPath = path.join(tempRoot, "openclaw.json");
153+
const inputPrefix = "i".repeat(499);
154+
const toolOutputPrefix = "o".repeat(3_999);
155+
await fs.writeFile(configPath, "{}\n", "utf8");
156+
const jsonResponse = (body: unknown) =>
157+
new Response(JSON.stringify(body), {
158+
headers: { "content-type": "application/json" },
159+
});
160+
const fetchMock = vi
161+
.fn()
162+
.mockResolvedValueOnce(jsonResponse([]))
163+
.mockResolvedValueOnce(jsonResponse({ output: [], status: "completed" }))
164+
.mockResolvedValueOnce(
165+
jsonResponse([
166+
{
167+
allInputText: `${inputPrefix}😀tail`,
168+
body: { tools: [] },
169+
plannedToolName: "fake_plugin_tool_17",
170+
raw: "{}",
171+
toolOutput: `${toolOutputPrefix}😀tail`,
172+
},
173+
]),
174+
);
175+
vi.stubGlobal("fetch", fetchMock);
176+
const env: QaSuiteRuntimeEnv = {
177+
alternateModel: "openai/gpt-5.5",
178+
cfg: {},
179+
gateway: {
180+
baseUrl: "http://gateway.test",
181+
call: async () => undefined,
182+
restartAfterStateMutation: async (mutateState) => {
183+
await mutateState({
184+
configPath,
185+
runtimeEnv: {},
186+
stateDir: path.join(tempRoot, "state"),
187+
tempRoot,
188+
});
189+
},
190+
runtimeEnv: { OPENCLAW_GATEWAY_TOKEN: "test-token" },
191+
tempRoot,
192+
workspaceDir: tempRoot,
193+
},
194+
mock: { baseUrl: "http://mock-openai.test" },
195+
primaryModel: "openai/gpt-5.5",
196+
providerMode: "mock-openai",
197+
repoRoot: tempRoot,
198+
transport: {} as QaSuiteRuntimeEnv["transport"],
199+
};
200+
201+
try {
202+
const result = await runToolSearchGatewayLane({
203+
env,
204+
fixture: { fakePluginDir: tempRoot, targetTool: "fake_plugin_tool_17" },
205+
lane: "normal",
206+
});
207+
208+
expect(result.providerInputSnippet).toBe(inputPrefix);
209+
expect(result.providerToolOutputSnippet).toBe(toolOutputPrefix);
210+
expect(fetchMock).toHaveBeenCalledTimes(3);
211+
} finally {
212+
await fs.rm(tempRoot, { force: true, recursive: true });
213+
}
214+
});
215+
});
216+
143217
describe("qa fixture response helpers", () => {
144218
it("reads Responses API text, function call names, and prompt sizing", () => {
145219
const payload = {
@@ -198,6 +272,33 @@ describe("tool search gateway e2e lane assertions", () => {
198272
).not.toThrow();
199273
});
200274

275+
it("preserves surrogate pairs in both lane debug output snippets", () => {
276+
const outputPrefix = `FAKE_PLUGIN_OK ${targetTool} `;
277+
const normalOutput = `${outputPrefix}${"n".repeat(299 - outputPrefix.length)}`;
278+
const codeOutput = `${outputPrefix}${"c".repeat(299 - outputPrefix.length)}`;
279+
const assertInvalidLaneResults = () =>
280+
assertToolSearchLaneResults({
281+
targetTool,
282+
normal: {
283+
...normal,
284+
gatewayOutputText: `${normalOutput}😀tail`,
285+
},
286+
code: {
287+
gatewayOutputText: `${codeOutput}😀tail`,
288+
providerDeclaredToolCount: 1,
289+
providerPlannedTools: ["tool_search_code", targetTool],
290+
providerRawBytes: 4_000,
291+
sessionLogToolMentions: {
292+
tool_search_code: 1,
293+
[targetTool]: 1,
294+
},
295+
},
296+
});
297+
298+
expect(assertInvalidLaneResults).toThrow(`"output": "${normalOutput}"`);
299+
expect(assertInvalidLaneResults).toThrow(`"output": "${codeOutput}"`);
300+
});
301+
201302
it("rejects code lane output that only echoes the target tool name", () => {
202303
expect(() =>
203304
assertToolSearchLaneResults({

extensions/qa-lab/src/tool-search-gateway.fixture.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
33
import path from "node:path";
44
import process from "node:process";
55
import { pathToFileURL } from "node:url";
6+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
67
import {
78
countSessionLogMentions,
89
countSystemPromptChars,
@@ -424,8 +425,11 @@ export async function runToolSearchGatewayLane(params: {
424425
providerRequestCount: laneRequests.length,
425426
providerRawBytes: typeof lastRequest.raw === "string" ? lastRequest.raw.length : 0,
426427
providerSystemPromptChars: countSystemPromptChars(lastRequest.body),
427-
providerInputSnippet: (lastRequest.allInputText ?? lastRequest.prompt ?? "").slice(0, 500),
428-
providerToolOutputSnippet: (lastRequest.toolOutput ?? "").slice(0, 4_000),
428+
providerInputSnippet: truncateUtf16Safe(
429+
lastRequest.allInputText ?? lastRequest.prompt ?? "",
430+
500,
431+
),
432+
providerToolOutputSnippet: truncateUtf16Safe(lastRequest.toolOutput ?? "", 4_000),
429433
providerDeclaredToolCount: Array.isArray(lastRequest.body?.tools)
430434
? lastRequest.body.tools.length
431435
: 0,
@@ -452,15 +456,15 @@ export function assertToolSearchLaneResults(params: {
452456
declaredToolCount: normal.providerDeclaredToolCount,
453457
input: normal.providerInputSnippet,
454458
toolOutput: normal.providerToolOutputSnippet,
455-
output: normal.gatewayOutputText.slice(0, 300),
459+
output: truncateUtf16Safe(normal.gatewayOutputText, 300),
456460
mentions: normal.sessionLogToolMentions,
457461
},
458462
code: {
459463
plannedTools: code.providerPlannedTools,
460464
declaredToolCount: code.providerDeclaredToolCount,
461465
input: code.providerInputSnippet,
462466
toolOutput: code.providerToolOutputSnippet,
463-
output: code.gatewayOutputText.slice(0, 300),
467+
output: truncateUtf16Safe(code.gatewayOutputText, 300),
464468
mentions: code.sessionLogToolMentions,
465469
},
466470
},

0 commit comments

Comments
 (0)