fix(agents): normalize whitespace-padded tool call names before dispatch (#27094)

Fix tool-call lookup failures when models emit whitespace-padded names by normalizing
both transcript history and live streamed embedded-runner tool calls before dispatch.

Co-authored-by: wangchunyue <80630709+openperf@users.noreply.github.com>
Co-authored-by: Sid <sidqin0410@gmail.com>
Co-authored-by: Philipp Spiess <hello@philippspiess.com>
This commit is contained in:
wangchunyue
2026-02-27 18:26:37 +08:00
committed by GitHub
parent aae90cb036
commit 6b317b1f17
5 changed files with 259 additions and 3 deletions

View File

@@ -4,6 +4,7 @@ import {
resolveAttemptFsWorkspaceOnly,
resolvePromptBuildHookResult,
resolvePromptModeForSession,
wrapStreamFnTrimToolCallNames,
} from "./attempt.js";
describe("resolvePromptBuildHookResult", () => {
@@ -103,3 +104,73 @@ describe("resolveAttemptFsWorkspaceOnly", () => {
).toBe(false);
});
});
describe("wrapStreamFnTrimToolCallNames", () => {
function createFakeStream(params: { events: unknown[]; resultMessage: unknown }): {
result: () => Promise<unknown>;
[Symbol.asyncIterator]: () => AsyncIterator<unknown>;
} {
return {
async result() {
return params.resultMessage;
},
[Symbol.asyncIterator]() {
return (async function* () {
for (const event of params.events) {
yield event;
}
})();
},
};
}
it("trims whitespace from live streamed tool call names and final result message", async () => {
const partialToolCall = { type: "toolCall", name: " read " };
const messageToolCall = { type: "toolCall", name: " exec " };
const finalToolCall = { type: "toolCall", name: " write " };
const event = {
type: "toolcall_delta",
partial: { role: "assistant", content: [partialToolCall] },
message: { role: "assistant", content: [messageToolCall] },
};
const finalMessage = { role: "assistant", content: [finalToolCall] };
const baseFn = vi.fn(() => createFakeStream({ events: [event], resultMessage: finalMessage }));
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never);
const stream = wrappedFn({} as never, {} as never, {} as never) as Awaited<
ReturnType<typeof wrappedFn>
>;
const seenEvents: unknown[] = [];
for await (const item of stream) {
seenEvents.push(item);
}
const result = await stream.result();
expect(seenEvents).toHaveLength(1);
expect(partialToolCall.name).toBe("read");
expect(messageToolCall.name).toBe("exec");
expect(finalToolCall.name).toBe("write");
expect(result).toBe(finalMessage);
expect(baseFn).toHaveBeenCalledTimes(1);
});
it("supports async stream functions that return a promise", async () => {
const finalToolCall = { type: "toolCall", name: " browser " };
const finalMessage = { role: "assistant", content: [finalToolCall] };
const baseFn = vi.fn(async () =>
createFakeStream({
events: [],
resultMessage: finalMessage,
}),
);
const wrappedFn = wrapStreamFnTrimToolCallNames(baseFn as never);
const stream = await wrappedFn({} as never, {} as never, {} as never);
const result = await stream.result();
expect(finalToolCall.name).toBe("browser");
expect(result).toBe(finalMessage);
expect(baseFn).toHaveBeenCalledTimes(1);
});
});