Files
openclaw/src/agents/transport-message-transform.test.ts
2026-05-09 18:00:46 +01:00

263 lines
9.0 KiB
TypeScript

import type { Api, Context, Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { transformTransportMessages } from "./transport-message-transform.js";
function makeModel(api: Api, provider: string, id: string): Model<Api> {
return { api, provider, id, input: [], output: [] } as unknown as Model<Api>;
}
type ToolResultMessage = Extract<Context["messages"][number], { role: "toolResult" }>;
function requireToolResultMessage(
message: Context["messages"][number] | undefined,
): ToolResultMessage {
if (!message || message.role !== "toolResult") {
throw new Error(`expected toolResult message, got ${message?.role ?? "missing"}`);
}
return message;
}
function toolResultSummaries(messages: Context["messages"]) {
return messages.map((message) => {
const toolResult = requireToolResultMessage(message);
return {
role: toolResult.role,
toolCallId: toolResult.toolCallId,
content: toolResult.content,
};
});
}
function assistantToolCall(
id: string,
name = "read",
stopReason: Extract<Context["messages"][number], { role: "assistant" }>["stopReason"] = "toolUse",
): Extract<Context["messages"][number], { role: "assistant" }> {
return {
role: "assistant",
provider: "openai",
api: "openai-responses",
model: "gpt-5.4",
stopReason,
timestamp: Date.now(),
content: [{ type: "toolCall", id, name, arguments: {} }],
} as Extract<Context["messages"][number], { role: "assistant" }>;
}
describe("transformTransportMessages synthetic tool-result policy", () => {
it("synthesizes Codex-style aborted tool results for OpenAI Responses transports", () => {
const messages: Context["messages"] = [
assistantToolCall("call_openai_1"),
{ role: "user", content: "continue", timestamp: Date.now() },
];
const result = transformTransportMessages(
messages,
makeModel("openai-responses", "openai", "gpt-5.4"),
);
expect(result.map((msg) => msg.role)).toEqual(["assistant", "toolResult", "user"]);
const toolResult = requireToolResultMessage(result[1]);
expect(toolResult.toolCallId).toBe("call_openai_1");
expect(toolResult.isError).toBe(true);
expect(toolResult.content).toEqual([{ type: "text", text: "aborted" }]);
});
it("preserves real OpenAI transport results and aborts missing parallel siblings", () => {
const messages: Context["messages"] = [
{
...assistantToolCall("call_keep"),
content: [
{ type: "toolCall", id: "call_keep", name: "read", arguments: {} },
{ type: "toolCall", id: "call_missing", name: "exec", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "call_keep",
toolName: "read",
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: Date.now(),
},
{ role: "user", content: "continue", timestamp: Date.now() },
];
const result = transformTransportMessages(
messages,
makeModel("openclaw-openai-responses-transport" as Api, "openai", "gpt-5.4"),
);
expect(result.map((msg) => msg.role)).toEqual([
"assistant",
"toolResult",
"toolResult",
"user",
]);
expect(toolResultSummaries(result.slice(1, 3))).toEqual([
{ role: "toolResult", toolCallId: "call_keep", content: [{ type: "text", text: "ok" }] },
{
role: "toolResult",
toolCallId: "call_missing",
content: [{ type: "text", text: "aborted" }],
},
]);
});
it("moves displaced OpenAI transport results before synthesizing missing siblings", () => {
const messages: Context["messages"] = [
{
...assistantToolCall("call_keep"),
content: [
{ type: "toolCall", id: "call_keep", name: "read", arguments: {} },
{ type: "toolCall", id: "call_missing", name: "exec", arguments: {} },
],
},
{ role: "user", content: "continue", timestamp: Date.now() },
{
role: "toolResult",
toolCallId: "call_keep",
toolName: "read",
content: [{ type: "text", text: "late ok" }],
isError: false,
timestamp: Date.now(),
},
];
const result = transformTransportMessages(
messages,
makeModel("openai-responses", "openai", "gpt-5.4"),
);
expect(result.map((msg) => msg.role)).toEqual([
"assistant",
"toolResult",
"toolResult",
"user",
]);
expect(toolResultSummaries(result.slice(1, 3))).toEqual([
{ role: "toolResult", toolCallId: "call_keep", content: [{ type: "text", text: "late ok" }] },
{
role: "toolResult",
toolCallId: "call_missing",
content: [{ type: "text", text: "aborted" }],
},
]);
});
it("drops aborted OpenAI transport assistant tool calls before replay", () => {
const messages: Context["messages"] = [
assistantToolCall("call_aborted", "exec", "aborted"),
{ role: "user", content: "retry after abort", timestamp: Date.now() },
];
const result = transformTransportMessages(
messages,
makeModel("openai-responses", "openai", "gpt-5.4"),
);
expect(result.map((msg) => msg.role)).toEqual(["user"]);
expect(JSON.stringify(result)).not.toContain("call_aborted");
});
it("drops text-only aborted and errored transport assistant turns before replay", () => {
const messages: Context["messages"] = [
{
role: "assistant",
provider: "openai",
api: "openai-responses",
model: "gpt-5.4",
stopReason: "aborted",
timestamp: Date.now(),
content: [{ type: "text", text: "partial aborted output" }],
} as Extract<Context["messages"][number], { role: "assistant" }>,
{
role: "assistant",
provider: "openai",
api: "openai-responses",
model: "gpt-5.4",
stopReason: "error",
timestamp: Date.now(),
content: [{ type: "text", text: "partial error output" }],
} as Extract<Context["messages"][number], { role: "assistant" }>,
{ role: "user", content: "retry after failed text turns", timestamp: Date.now() },
];
const result = transformTransportMessages(
messages,
makeModel("openai-responses", "openai", "gpt-5.4"),
);
expect(result.map((msg) => msg.role)).toEqual(["user"]);
expect(JSON.stringify(result)).not.toContain("partial aborted output");
expect(JSON.stringify(result)).not.toContain("partial error output");
});
it("drops errored Anthropic transport assistant tool calls and matching results before replay", () => {
const messages: Context["messages"] = [
assistantToolCall("call_error", "exec", "error"),
{
role: "toolResult",
toolCallId: "call_error",
toolName: "exec",
content: [{ type: "text", text: "partial" }],
isError: true,
timestamp: Date.now(),
},
{ role: "user", content: "retry after error", timestamp: Date.now() },
];
const result = transformTransportMessages(
messages,
makeModel("anthropic-messages", "anthropic", "claude-opus-4-6"),
);
expect(result.map((msg) => msg.role)).toEqual(["user"]);
expect(JSON.stringify(result)).not.toContain("call_error");
});
it("still synthesizes missing tool results for Anthropic transports", () => {
const messages: Context["messages"] = [
assistantToolCall("call_anthropic_1"),
{ role: "user", content: "continue", timestamp: Date.now() },
];
const result = transformTransportMessages(
messages,
makeModel("anthropic-messages", "anthropic", "claude-opus-4-6"),
);
expect(result.map((msg) => msg.role)).toEqual(["assistant", "toolResult", "user"]);
const toolResult = requireToolResultMessage(result[1]);
expect(toolResult.toolCallId).toBe("call_anthropic_1");
expect(toolResult.isError).toBe(true);
});
it("still synthesizes missing tool results for transport alias apis that own replay repair", () => {
const messages: Context["messages"] = [
assistantToolCall("call_transport_1"),
{ role: "user", content: "continue", timestamp: Date.now() },
];
const anthropicAlias = transformTransportMessages(
messages,
makeModel("openclaw-anthropic-messages-transport" as Api, "anthropic", "claude-opus-4-6"),
);
expect(anthropicAlias.map((msg) => msg.role)).toEqual(["assistant", "toolResult", "user"]);
const googleAlias = transformTransportMessages(
messages,
makeModel("openclaw-google-generative-ai-transport" as Api, "google", "gemini-2.5-pro"),
);
expect(googleAlias.map((msg) => msg.role)).toEqual(["assistant", "toolResult", "user"]);
const googleToolResult = requireToolResultMessage(googleAlias[1]);
expect(googleToolResult.content).toEqual([{ type: "text", text: "No result provided" }]);
const bedrockCanonical = transformTransportMessages(
messages,
makeModel("bedrock-converse-stream" as Api, "bedrock", "anthropic.claude-opus-4-6"),
);
expect(bedrockCanonical.map((msg) => msg.role)).toEqual(["assistant", "toolResult", "user"]);
});
});