Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
// Google shared conversion tests cover runtime-to-Google payload conversion.
|
|
import { describe, expect, it } from "vitest";
|
|
import type { Context, Tool } from "../types.js";
|
|
import { convertMessages, convertTools } from "./google-shared.js";
|
|
import {
|
|
asRecord,
|
|
expectConvertedRoles,
|
|
getFirstToolParameters,
|
|
makeGeminiCliAssistantMessage,
|
|
makeGeminiCliModel,
|
|
makeGoogleAssistantMessage,
|
|
makeModel,
|
|
} from "./google-shared.test-helpers.js";
|
|
|
|
type GoogleSharedTestModel = ReturnType<typeof makeModel> | ReturnType<typeof makeGeminiCliModel>;
|
|
const convertMessagesForTest = convertMessages as unknown as (
|
|
model: GoogleSharedTestModel,
|
|
context: Context,
|
|
) => ReturnType<typeof convertMessages>;
|
|
|
|
function requireRecordProperty(
|
|
record: Record<string, unknown>,
|
|
key: string,
|
|
): Record<string, unknown> {
|
|
const value = record[key];
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`expected object property ${key}`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
describe("google-shared convertTools", () => {
|
|
it("preserves parameters when type is missing", () => {
|
|
const tools = [
|
|
{
|
|
name: "noType",
|
|
description: "Tool with properties but no type",
|
|
parameters: {
|
|
properties: {
|
|
action: { type: "string" },
|
|
},
|
|
required: ["action"],
|
|
},
|
|
},
|
|
] as unknown as Tool[];
|
|
|
|
const converted = convertTools(tools);
|
|
const params = getFirstToolParameters(
|
|
converted as Parameters<typeof getFirstToolParameters>[0],
|
|
);
|
|
|
|
expect(params.type).toBeUndefined();
|
|
expect(params.properties).toEqual({
|
|
action: { type: "string" },
|
|
});
|
|
expect(params.required).toEqual(["action"]);
|
|
});
|
|
|
|
it("keeps unsupported JSON Schema keywords intact", () => {
|
|
const tools = [
|
|
{
|
|
name: "example",
|
|
description: "Example tool",
|
|
parameters: {
|
|
type: "object",
|
|
patternProperties: {
|
|
"^x-": { type: "string" },
|
|
},
|
|
additionalProperties: false,
|
|
properties: {
|
|
mode: {
|
|
type: "string",
|
|
const: "fast",
|
|
},
|
|
options: {
|
|
anyOf: [{ type: "string" }, { type: "number" }],
|
|
},
|
|
list: {
|
|
type: "array",
|
|
items: {
|
|
type: "string",
|
|
const: "item",
|
|
},
|
|
},
|
|
},
|
|
required: ["mode"],
|
|
},
|
|
},
|
|
] as unknown as Tool[];
|
|
|
|
const converted = convertTools(tools);
|
|
const params = getFirstToolParameters(
|
|
converted as Parameters<typeof getFirstToolParameters>[0],
|
|
);
|
|
const properties = asRecord(params.properties);
|
|
const mode = asRecord(properties.mode);
|
|
const options = asRecord(properties.options);
|
|
const list = asRecord(properties.list);
|
|
const items = asRecord(list.items);
|
|
|
|
expect(params.patternProperties).toEqual({ "^x-": { type: "string" } });
|
|
expect(params.additionalProperties).toBe(false);
|
|
expect(mode.const).toBe("fast");
|
|
expect(options.anyOf).toEqual([{ type: "string" }, { type: "number" }]);
|
|
expect(items.const).toBe("item");
|
|
expect(params.required).toEqual(["mode"]);
|
|
});
|
|
|
|
it("keeps supported schema fields", () => {
|
|
const tools = [
|
|
{
|
|
name: "settings",
|
|
description: "Settings tool",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
config: {
|
|
type: "object",
|
|
properties: {
|
|
retries: { type: "number", minimum: 1 },
|
|
tags: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
},
|
|
},
|
|
required: ["retries"],
|
|
},
|
|
},
|
|
required: ["config"],
|
|
},
|
|
},
|
|
] as unknown as Tool[];
|
|
|
|
const converted = convertTools(tools);
|
|
const params = getFirstToolParameters(
|
|
converted as Parameters<typeof getFirstToolParameters>[0],
|
|
);
|
|
const config = asRecord(asRecord(params.properties).config);
|
|
const configProps = asRecord(config.properties);
|
|
const retries = asRecord(configProps.retries);
|
|
const tags = asRecord(configProps.tags);
|
|
const items = asRecord(tags.items);
|
|
|
|
expect(params.type).toBe("object");
|
|
expect(config.type).toBe("object");
|
|
expect(retries.minimum).toBe(1);
|
|
expect(tags.type).toBe("array");
|
|
expect(items.type).toBe("string");
|
|
expect(config.required).toEqual(["retries"]);
|
|
expect(params.required).toEqual(["config"]);
|
|
});
|
|
});
|
|
|
|
describe("google-shared convertMessages", () => {
|
|
function expectConsecutiveMessagesNotMerged(params: {
|
|
modelId: string;
|
|
first: string;
|
|
second: string;
|
|
}) {
|
|
const model = makeModel(params.modelId);
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: params.first,
|
|
},
|
|
{
|
|
role: "user",
|
|
content: params.second,
|
|
},
|
|
],
|
|
} as unknown as Context;
|
|
|
|
const contents = convertMessagesForTest(model, context);
|
|
expect(contents).toHaveLength(2);
|
|
expect(contents[0].role).toBe("user");
|
|
expect(contents[1].role).toBe("user");
|
|
expect(contents[0].parts).toHaveLength(1);
|
|
expect(contents[1].parts).toHaveLength(1);
|
|
}
|
|
|
|
it("keeps thinking blocks when provider/model match", () => {
|
|
const model = makeModel("gemini-1.5-pro");
|
|
const context = {
|
|
messages: [
|
|
makeGoogleAssistantMessage(model.id, [
|
|
{
|
|
type: "thinking",
|
|
thinking: "hidden",
|
|
thinkingSignature: "c2ln",
|
|
},
|
|
]),
|
|
],
|
|
} as unknown as Context;
|
|
|
|
const contents = convertMessagesForTest(model, context);
|
|
expect(contents).toHaveLength(1);
|
|
expect(contents[0].role).toBe("model");
|
|
const part = asRecord(contents[0].parts?.[0]);
|
|
expect(part.thought).toBe(true);
|
|
expect(part.thoughtSignature).toBe("c2ln");
|
|
});
|
|
|
|
it("keeps thought signatures for Claude models", () => {
|
|
const model = makeModel("claude-3-opus");
|
|
const context = {
|
|
messages: [
|
|
makeGoogleAssistantMessage(model.id, [
|
|
{
|
|
type: "thinking",
|
|
thinking: "structured",
|
|
thinkingSignature: "c2ln",
|
|
},
|
|
]),
|
|
],
|
|
} as unknown as Context;
|
|
|
|
const contents = convertMessagesForTest(model, context);
|
|
const parts = contents?.[0]?.parts ?? [];
|
|
expect(parts).toHaveLength(1);
|
|
const part = asRecord(parts[0]);
|
|
expect(part.thought).toBe(true);
|
|
expect(part.thoughtSignature).toBe("c2ln");
|
|
});
|
|
|
|
it("does not merge consecutive user messages for Gemini", () => {
|
|
expectConsecutiveMessagesNotMerged({
|
|
modelId: "gemini-1.5-pro",
|
|
first: "Hello",
|
|
second: "How are you?",
|
|
});
|
|
});
|
|
|
|
it("does not merge consecutive user messages for non-Gemini Google models", () => {
|
|
expectConsecutiveMessagesNotMerged({
|
|
modelId: "claude-3-opus",
|
|
first: "First",
|
|
second: "Second",
|
|
});
|
|
});
|
|
|
|
it("does not merge consecutive model messages for Gemini", () => {
|
|
const model = makeModel("gemini-1.5-pro");
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: "Hello",
|
|
},
|
|
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "Hi there!" }]),
|
|
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "How can I help?" }]),
|
|
],
|
|
} as unknown as Context;
|
|
|
|
const contents = convertMessagesForTest(model, context);
|
|
expectConvertedRoles(contents, ["user", "model", "model"]);
|
|
expect(contents[1].parts).toHaveLength(1);
|
|
expect(contents[2].parts).toHaveLength(1);
|
|
});
|
|
|
|
it("handles user message after tool result without model response in between", () => {
|
|
const model = makeModel("gemini-1.5-pro");
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: "Use a tool",
|
|
},
|
|
makeGoogleAssistantMessage(model.id, [
|
|
{
|
|
type: "toolCall",
|
|
id: "call_1",
|
|
name: "myTool",
|
|
arguments: { arg: "value" },
|
|
},
|
|
]),
|
|
{
|
|
role: "toolResult",
|
|
toolCallId: "call_1",
|
|
toolName: "myTool",
|
|
content: [{ type: "text", text: "Tool result" }],
|
|
isError: false,
|
|
timestamp: 0,
|
|
},
|
|
{
|
|
role: "user",
|
|
content: "Now do something else",
|
|
},
|
|
],
|
|
} as unknown as Context;
|
|
|
|
const contents = convertMessagesForTest(model, context);
|
|
expect(contents).toHaveLength(4);
|
|
expect(contents[0].role).toBe("user");
|
|
expect(contents[1].role).toBe("model");
|
|
expect(contents[2].role).toBe("user");
|
|
expect(contents[3].role).toBe("user");
|
|
const toolResponsePart = contents[2].parts?.find(
|
|
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
|
|
);
|
|
const toolResponse = asRecord(toolResponsePart);
|
|
expect(requireRecordProperty(toolResponse, "functionResponse").name).toBe("myTool");
|
|
expect(contents[3].role).toBe("user");
|
|
});
|
|
|
|
it("ensures function call comes after user turn, not after model turn", () => {
|
|
const model = makeModel("gemini-1.5-pro");
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: "Hello",
|
|
},
|
|
makeGoogleAssistantMessage(model.id, [{ type: "text", text: "Hi!" }]),
|
|
makeGoogleAssistantMessage(model.id, [
|
|
{
|
|
type: "toolCall",
|
|
id: "call_1",
|
|
name: "myTool",
|
|
arguments: {},
|
|
},
|
|
]),
|
|
],
|
|
} as unknown as Context;
|
|
|
|
const contents = convertMessagesForTest(model, context);
|
|
expectConvertedRoles(contents, ["user", "model", "model", "user"]);
|
|
const toolCallPart = contents[2].parts?.find(
|
|
(part) => typeof part === "object" && part !== null && "functionCall" in part,
|
|
);
|
|
const toolCall = asRecord(toolCallPart);
|
|
expect(requireRecordProperty(toolCall, "functionCall").name).toBe("myTool");
|
|
});
|
|
|
|
it("strips tool call and response ids for google-gemini-cli", () => {
|
|
const model = makeGeminiCliModel("gemini-3-flash");
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: "Use a tool",
|
|
},
|
|
makeGeminiCliAssistantMessage(model.id, [
|
|
{
|
|
type: "toolCall",
|
|
id: "call_1",
|
|
name: "myTool",
|
|
arguments: { arg: "value" },
|
|
thoughtSignature: "dGVzdA==",
|
|
},
|
|
]),
|
|
{
|
|
role: "toolResult",
|
|
toolCallId: "call_1",
|
|
toolName: "myTool",
|
|
content: [{ type: "text", text: "Tool result" }],
|
|
isError: false,
|
|
timestamp: 0,
|
|
},
|
|
],
|
|
} as unknown as Context;
|
|
|
|
const contents = convertMessagesForTest(model, context);
|
|
const parts = contents.flatMap((content) => content.parts ?? []);
|
|
const toolCallPart = parts.find(
|
|
(part) => typeof part === "object" && part !== null && "functionCall" in part,
|
|
);
|
|
const toolResponsePart = parts.find(
|
|
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
|
|
);
|
|
|
|
const toolCall = asRecord(toolCallPart);
|
|
const toolResponse = asRecord(toolResponsePart);
|
|
|
|
expect(asRecord(toolCall.functionCall).id).toBeUndefined();
|
|
expect(asRecord(toolResponse.functionResponse).id).toBeUndefined();
|
|
});
|
|
|
|
it("serializes structured tool results into function responses", () => {
|
|
const model = makeModel("gemini-1.5-pro");
|
|
const context = {
|
|
messages: [
|
|
{
|
|
role: "toolResult",
|
|
toolCallId: "call_1",
|
|
toolName: "session_status",
|
|
content: [{ type: "json", payload: { sessionKey: "current", status: "ok" } }],
|
|
isError: false,
|
|
timestamp: 0,
|
|
},
|
|
],
|
|
} as unknown as Context;
|
|
const contents = convertMessagesForTest(model, context);
|
|
const toolResponsePart = contents[0]?.parts?.find(
|
|
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
|
|
);
|
|
expect(toolResponsePart).toBeDefined();
|
|
const toolResponse = requireRecordProperty(asRecord(toolResponsePart), "functionResponse");
|
|
expect(asRecord(toolResponse.response).output).toBe(
|
|
'{"type":"json","payload":{"sessionKey":"current","status":"ok"}}',
|
|
);
|
|
});
|
|
});
|