mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 20:01:15 +00:00
* fix(agents): decode full HTML entity set * fix(agents): preserve mixed-case nbsp extraction * chore: leave release notes to release automation * chore(ci): lower web fetch LOC baseline * fix(agents): use literal entity radices
111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
// Tool-call argument decoding tests cover HTML entity repair for model-emitted
|
|
// tool arguments without corrupting invalid numeric entities.
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
createHtmlEntityToolCallArgumentDecodingWrapper,
|
|
decodeHtmlEntitiesInObject,
|
|
} from "./tool-call-argument-decoding.js";
|
|
|
|
describe("decodeHtmlEntitiesInObject", () => {
|
|
it("decodes valid HTML entities in nested tool arguments", () => {
|
|
expect(
|
|
decodeHtmlEntitiesInObject({
|
|
query: "Rock & Roll A 'ok' 'hex'",
|
|
emoji: "ok 😀",
|
|
args: ["--flag="value"", "<input>"],
|
|
nested: { deep: "a & b — ©" },
|
|
}),
|
|
).toEqual({
|
|
query: "Rock & Roll A 'ok' 'hex'",
|
|
emoji: "ok 😀",
|
|
args: ['--flag="value"', "<input>"],
|
|
nested: { deep: "a & b — ©" },
|
|
});
|
|
});
|
|
|
|
it("passes through primitives and strings without entities", () => {
|
|
expect(decodeHtmlEntitiesInObject(42)).toBe(42);
|
|
expect(decodeHtmlEntitiesInObject(null)).toBe(null);
|
|
expect(decodeHtmlEntitiesInObject(true)).toBe(true);
|
|
expect(decodeHtmlEntitiesInObject(undefined)).toBe(undefined);
|
|
expect(decodeHtmlEntitiesInObject("plain string")).toBe("plain string");
|
|
});
|
|
|
|
it("preserves invalid numeric HTML entities", () => {
|
|
expect(
|
|
decodeHtmlEntitiesInObject({
|
|
query: "bad � and � and � and �",
|
|
}),
|
|
).toEqual({
|
|
query: "bad � and � and � and �",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("createHtmlEntityToolCallArgumentDecodingWrapper", () => {
|
|
type DecodedMessage = { content: Array<{ arguments: { content: string } }> };
|
|
|
|
const buildSharedArgumentsAssistant = () => {
|
|
const toolCall = {
|
|
type: "toolCall" as const,
|
|
id: "call_1",
|
|
name: "write",
|
|
arguments: { content: "&amp;" },
|
|
};
|
|
const assistant = { role: "assistant" as const, content: [toolCall] };
|
|
const events = [
|
|
{ type: "toolcall_end", contentIndex: 0, toolCall, partial: assistant },
|
|
{ type: "done", reason: "toolUse", message: assistant },
|
|
];
|
|
const baseStreamFn = (() => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
for (const event of events) {
|
|
yield event;
|
|
}
|
|
},
|
|
async result() {
|
|
return assistant;
|
|
},
|
|
})) as never;
|
|
return { assistant, baseStreamFn };
|
|
};
|
|
|
|
const drive = async (baseStreamFn: never): Promise<DecodedMessage> => {
|
|
const wrapped = createHtmlEntityToolCallArgumentDecodingWrapper(baseStreamFn);
|
|
const stream = wrapped({} as never, {} as never, {} as never) as unknown as {
|
|
[Symbol.asyncIterator](): AsyncIterator<unknown>;
|
|
result(): Promise<DecodedMessage>;
|
|
};
|
|
for await (const event of stream as AsyncIterable<unknown>) {
|
|
void event;
|
|
}
|
|
return stream.result();
|
|
};
|
|
|
|
it("decodes a shared tool-call arguments object exactly once, keyed by object identity, across its partial, message, and result()", async () => {
|
|
const { baseStreamFn } = buildSharedArgumentsAssistant();
|
|
|
|
const finalMessage = await drive(baseStreamFn);
|
|
|
|
expect(finalMessage.content[0]?.arguments.content).toBe("&");
|
|
});
|
|
|
|
it("decodes the same arguments object once even when it flows through two independent wrapper invocations (the guard spans wrapper instances, not a single stream)", async () => {
|
|
const { assistant, baseStreamFn } = buildSharedArgumentsAssistant();
|
|
const secondStreamFn = (() => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
yield { type: "done", reason: "toolUse", message: assistant };
|
|
},
|
|
async result() {
|
|
return assistant;
|
|
},
|
|
})) as never;
|
|
|
|
const first = await drive(baseStreamFn);
|
|
const second = await drive(secondStreamFn);
|
|
|
|
expect(first.content[0]?.arguments.content).toBe("&");
|
|
expect(second.content[0]?.arguments.content).toBe("&");
|
|
});
|
|
});
|