mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 23:41:35 +00:00
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:
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user