fix: repair replay tool result pairing

Regeneration-Prompt: |
  Address the PR review finding that replay sanitization can drop an assistant tool-call turn and leave downstream toolResult messages orphaned in the outbound provider context. Keep the replay-only sanitizer from the previous review fix, but when it changes the message list, immediately run sanitizeToolUseResultPairing before handing the context to the provider. Add a regression test that starts with a dropped malformed assistant tool-call turn followed by a toolResult and verifies the orphaned result is removed.
This commit is contained in:
Josh Lehman
2026-03-18 13:35:19 -07:00
parent 41fd97129e
commit 42c9a1e6cf
2 changed files with 43 additions and 1 deletions

View File

@@ -878,6 +878,47 @@ describe("wrapStreamFnSanitizeMalformedToolCalls", () => {
expect(toolCall.name).toBe("SESSIONS_SPAWN");
expect(toolCall.input?.attachments?.[0]?.content).toBe(attachmentContent);
});
it("drops orphaned tool results after replay sanitization removes a tool-call turn", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", name: "read", arguments: {} }],
stopReason: "error",
},
{
role: "toolResult",
toolCallId: "call_missing",
toolName: "read",
content: [{ type: "text", text: "stale result" }],
isError: false,
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string }>;
};
expect(seenContext.messages).toEqual([
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
});
});
describe("wrapStreamFnRepairMalformedToolCallArguments", () => {

View File

@@ -916,9 +916,10 @@ export function wrapStreamFnSanitizeMalformedToolCalls(
if (sanitized === messages) {
return baseFn(model, context, options);
}
const paired = sanitizeToolUseResultPairing(sanitized);
const nextContext = {
...(context as unknown as Record<string, unknown>),
messages: sanitized,
messages: paired,
} as unknown;
return baseFn(model, nextContext as typeof context, options);
};