refactor(providers): consolidate contract fixtures (#114424)

This commit is contained in:
Peter Steinberger
2026-08-09 07:07:47 -07:00
committed by GitHub
parent 2e6c2bba19
commit 00194139ba
5 changed files with 557 additions and 1028 deletions
+116 -190
View File
@@ -74,6 +74,16 @@ function levelIds(profile: unknown): Array<unknown> {
return (levels as Array<{ id?: unknown }>).map((level) => level.id);
}
type Claude5ContractCase = {
name: string;
modelId: string;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
thinkingLevelMap: Record<string, string>;
checksMedia?: boolean;
restoresMissingCost?: boolean;
checksCliPolicy?: boolean;
};
const ANTHROPIC_SETUP_TOKEN = `sk-ant-oat01-${"a".repeat(80)}`;
describe("anthropic provider replay hooks", () => {
@@ -637,205 +647,121 @@ describe("anthropic provider replay hooks", () => {
).toBe(false);
});
it("resolves Claude Opus 5 with its exact API contract", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const resolved = provider.resolveDynamicModel?.({
provider: "anthropic",
const claude5ContractCases: Claude5ContractCase[] = [
{
name: "resolves Claude Opus 5 with its exact API contract",
modelId: "claude-opus-5",
modelRegistry: createModelRegistry([]),
} as ProviderResolveDynamicModelContext);
expectFields(resolved, {
provider: "anthropic",
id: "claude-opus-5",
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
contextTokens: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
});
expect(requireRecord(resolved, "Opus 5 model").mediaInput).toEqual({
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
});
const profile = provider.resolveThinkingProfile?.({
provider: "anthropic",
modelId: "claude-opus-5",
} as never);
expect(levelIds(profile)).toStrictEqual([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive",
"max",
]);
expect(requireRecord(profile, "Opus 5 thinking profile").defaultLevel).toBe("high");
const normalized = provider.normalizeResolvedModel?.({
provider: "anthropic",
modelId: "claude-opus-5",
model: {
...(resolved as ProviderRuntimeModel),
reasoning: false,
cost: undefined,
contextWindow: 200_000,
contextTokens: 200_000,
maxTokens: 64_000,
} as unknown as ProviderRuntimeModel,
} as never);
expectFields(normalized, {
reasoning: true,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
contextTokens: 1_000_000,
maxTokens: 128_000,
});
});
it("resolves Claude Fable 5 with its always-adaptive model contract", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const resolved = provider.resolveDynamicModel?.({
provider: "anthropic",
checksMedia: true,
restoresMissingCost: true,
},
{
name: "resolves Claude Fable 5 with its always-adaptive model contract",
modelId: "claude-fable-5",
modelRegistry: createModelRegistry([]),
} as ProviderResolveDynamicModelContext);
expectFields(resolved, {
provider: "anthropic",
id: "claude-fable-5",
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
contextWindow: 1_000_000,
contextTokens: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: {
off: "low",
minimal: "low",
xhigh: "xhigh",
max: "max",
},
});
expect(requireRecord(resolved, "Fable model").mediaInput).toEqual({
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
});
const profile = provider.resolveThinkingProfile?.({
provider: "anthropic",
modelId: "claude-fable-5",
} as never);
expect(levelIds(profile)).toStrictEqual([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive",
"max",
]);
expect(requireRecord(profile, "Fable thinking profile").defaultLevel).toBe("high");
const normalized = provider.normalizeResolvedModel?.({
provider: "anthropic",
modelId: "claude-fable-5",
model: {
...(resolved as ProviderRuntimeModel),
reasoning: false,
},
} as never);
expect(normalized?.reasoning).toBe(true);
expect(
provider.resolveDynamicModel?.({
provider: "claude-cli",
modelId: "claude-fable-5",
modelRegistry: createModelRegistry([]),
} as ProviderResolveDynamicModelContext),
).toBeUndefined();
expect(
provider.resolveThinkingProfile?.({
provider: "claude-cli",
modelId: "claude-fable-5",
} as never),
).toEqual(profile);
expect(
provider
.resolveThinkingProfile?.({
provider: "claude-cli",
modelId: "claude-opus-4-6",
} as never)
?.levels.map((level) => level.id),
).toContain("max");
expect(
provider.isModernModelRef?.({
provider: "claude-cli",
modelId: "claude-fable-5",
}),
).toBe(false);
});
it("resolves Claude Sonnet 5 with its exact API contract", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const resolved = provider.resolveDynamicModel?.({
provider: "anthropic",
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
checksMedia: true,
checksCliPolicy: true,
},
{
name: "resolves Claude Sonnet 5 with its exact API contract",
modelId: "claude-sonnet-5",
modelRegistry: createModelRegistry([]),
} as ProviderResolveDynamicModelContext);
expectFields(resolved, {
provider: "anthropic",
id: "claude-sonnet-5",
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
});
},
];
const profile = provider.resolveThinkingProfile?.({
provider: "anthropic",
modelId: "claude-sonnet-5",
} as never);
expect(levelIds(profile)).toStrictEqual([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive",
"max",
]);
expect(requireRecord(profile, "Sonnet 5 thinking profile").defaultLevel).toBe("high");
const normalized = provider.normalizeResolvedModel?.({
provider: "anthropic",
modelId: "claude-sonnet-5",
model: {
...(resolved as ProviderRuntimeModel),
reasoning: false,
contextWindow: 200_000,
contextTokens: 200_000,
maxTokens: 64_000,
},
} as never);
expectFields(normalized, {
reasoning: true,
contextWindow: 1_000_000,
contextTokens: 1_000_000,
maxTokens: 128_000,
});
});
it.each(claude5ContractCases)(
"$name",
async ({
modelId,
cost,
thinkingLevelMap,
checksMedia,
restoresMissingCost,
checksCliPolicy,
}) => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const resolved = provider.resolveDynamicModel?.({
provider: "anthropic",
modelId,
modelRegistry: createModelRegistry([]),
} as ProviderResolveDynamicModelContext);
expectFields(resolved, {
provider: "anthropic",
id: modelId,
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost,
contextWindow: 1_000_000,
contextTokens: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap,
});
if (checksMedia) {
expect(requireRecord(resolved, `${modelId} model`).mediaInput).toEqual({
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
});
}
const profile = provider.resolveThinkingProfile?.({
provider: "anthropic",
modelId,
} as never);
expect(levelIds(profile)).toStrictEqual([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive",
"max",
]);
expect(requireRecord(profile, `${modelId} thinking profile`).defaultLevel).toBe("high");
const normalized = provider.normalizeResolvedModel?.({
provider: "anthropic",
modelId,
model: {
...(resolved as ProviderRuntimeModel),
reasoning: false,
...(checksCliPolicy
? {}
: { contextWindow: 200_000, contextTokens: 200_000, maxTokens: 64_000 }),
...(restoresMissingCost ? { cost: undefined } : {}),
} as ProviderRuntimeModel,
} as never);
expectFields(normalized, {
reasoning: true,
...(checksCliPolicy
? {}
: { contextWindow: 1_000_000, contextTokens: 1_000_000, maxTokens: 128_000 }),
...(restoresMissingCost ? { cost } : {}),
});
if (checksCliPolicy) {
expect(
provider.resolveDynamicModel?.({
provider: "claude-cli",
modelId,
modelRegistry: createModelRegistry([]),
} as ProviderResolveDynamicModelContext),
).toBeUndefined();
expect(
provider.resolveThinkingProfile?.({ provider: "claude-cli", modelId } as never),
).toEqual(profile);
expect(
provider
.resolveThinkingProfile?.({
provider: "claude-cli",
modelId: "claude-opus-4-6",
} as never)
?.levels.map((level) => level.id),
).toContain("max");
expect(provider.isModernModelRef?.({ provider: "claude-cli", modelId })).toBe(false);
}
},
);
it("normalizes a Sonnet 5 model without cost metadata instead of crashing", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
+167 -239
View File
@@ -162,6 +162,39 @@ async function writeDesktopMetadata(
await fs.writeFile(path.join(dir, `local_${name}.json`), JSON.stringify(metadata));
}
async function writeIndexedDesktopSession(
home: string,
params: {
sessionId: string;
localSessionId: string;
metadataName: string;
title: string;
prompt: string;
metadata?: Record<string, unknown>;
},
): Promise<void> {
const { sessionId, localSessionId, metadataName, title, prompt, metadata } = params;
await writeProject({
home,
entries: [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: "/work/openclaw",
isSidechain: false,
},
],
transcripts: { [sessionId]: [message(sessionId, "user", prompt, 1)] },
});
await writeDesktopMetadata(home, metadataName, {
sessionId: localSessionId,
cliSessionId: sessionId,
cwd: "/work/openclaw",
title,
...metadata,
});
}
function encodeVarint(value: number): Buffer {
const bytes: number[] = [];
let remaining = value;
@@ -804,28 +837,16 @@ describe("Claude session catalog", () => {
],
transcripts: { [sessionId]: [message(sessionId, "user", "source prompt", 1)] },
});
let provider: SessionCatalogProvider | undefined;
const api = {
id: "anthropic",
config: {},
runtime: {
config: { current: () => ({}) },
agent: {
session: {
listSessionEntries: () => [
{
sessionKey: "agent:main:claude-bound",
entry: entry(sessionId),
},
],
},
const provider = captureCatalogProvider({
config: { current: () => ({}) },
agent: {
session: {
listSessionEntries: () => [
{ sessionKey: "agent:main:claude-bound", entry: entry(sessionId) },
],
},
},
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
provider = candidate;
},
} as unknown as OpenClawPluginApi;
registerClaudeSessionCatalog(api);
} as unknown as PluginRuntime);
const hosts = await provider?.list({});
expect(hosts?.[0]?.sessions[0]?.sessionKey).toBe("agent:main:claude-bound");
@@ -859,25 +880,11 @@ describe("Claude session catalog", () => {
sessionId: "openclaw-adopted",
entry: { sessionId: "openclaw-adopted", updatedAt: Date.now() },
}));
let provider: SessionCatalogProvider | undefined;
const api = {
id: "anthropic",
config: {},
runtime: {
config: { current: () => ({}) },
nodes: { list: async () => ({ nodes: [] }) },
agent: {
session: {
listSessionEntries: () => [],
createSessionEntry,
},
},
},
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
provider = candidate;
},
} as unknown as OpenClawPluginApi;
registerClaudeSessionCatalog(api);
const provider = captureCatalogProvider({
config: { current: () => ({}) },
nodes: { list: async () => ({ nodes: [] }) },
agent: { session: { listSessionEntries: () => [], createSessionEntry } },
} as unknown as PluginRuntime);
const hosts = await provider?.list({});
expect(hosts?.[0]?.sessions).toEqual([
@@ -1219,23 +1226,12 @@ describe("Claude session catalog", () => {
const home = await createHome();
const sessionId = "desktop-custom-group";
const localSessionId = "local_11111111-1111-1111-1111-111111111111";
await writeProject({
home,
entries: [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: "/work/openclaw",
isSidechain: false,
},
],
transcripts: { [sessionId]: [message(sessionId, "user", "custom group prompt", 1)] },
});
await writeDesktopMetadata(home, "custom-group", {
sessionId: localSessionId,
cliSessionId: sessionId,
cwd: "/work/openclaw",
await writeIndexedDesktopSession(home, {
sessionId,
localSessionId,
metadataName: "custom-group",
title: "Desktop custom group",
prompt: "custom group prompt",
});
await writeDesktopGroupStore(
home,
@@ -1252,32 +1248,23 @@ describe("Claude session catalog", () => {
it("retains the current Claude Desktop pull request when history is truncated", async () => {
const home = await createHome();
const sessionId = "desktop-pull-requests";
await writeProject({
home,
entries: [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: "/work/openclaw",
isSidechain: false,
},
],
transcripts: { [sessionId]: [message(sessionId, "user", "pull request prompt", 1)] },
});
await writeDesktopMetadata(home, "pull-requests", {
sessionId: "local_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
cliSessionId: sessionId,
cwd: "/work/openclaw",
await writeIndexedDesktopSession(home, {
sessionId,
localSessionId: "local_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
metadataName: "pull-requests",
title: "Desktop pull requests",
prNumber: 111772,
prs: [
{ prNumber: 111772, state: "MERGED" },
{ prNumber: 111179, state: "MERGED", dismissed: true },
...Array.from({ length: 1_000 }, (_value, index) => ({
prNumber: index + 1,
state: "CLOSED",
})),
],
prompt: "pull request prompt",
metadata: {
prNumber: 111772,
prs: [
{ prNumber: 111772, state: "MERGED" },
{ prNumber: 111179, state: "MERGED", dismissed: true },
...Array.from({ length: 1_000 }, (_value, index) => ({
prNumber: index + 1,
state: "CLOSED",
})),
],
},
});
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
@@ -1297,26 +1284,17 @@ describe("Claude session catalog", () => {
it("adds the current Claude Desktop pull request when history omits it", async () => {
const home = await createHome();
const sessionId = "desktop-current-pull-request";
await writeProject({
home,
entries: [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: "/work/openclaw",
isSidechain: false,
},
],
transcripts: { [sessionId]: [message(sessionId, "user", "draft prompt", 1)] },
});
await writeDesktopMetadata(home, "current-pull-request", {
sessionId: "local_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
cliSessionId: sessionId,
cwd: "/work/openclaw",
await writeIndexedDesktopSession(home, {
sessionId,
localSessionId: "local_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
metadataName: "current-pull-request",
title: "Desktop pull request",
prNumber: 107302,
prState: "OPEN",
prs: [{ prNumber: 107301, state: "CLOSED" }],
prompt: "draft prompt",
metadata: {
prNumber: 107302,
prState: "OPEN",
prs: [{ prNumber: 107301, state: "CLOSED" }],
},
});
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
@@ -1330,156 +1308,106 @@ describe("Claude session catalog", () => {
});
});
it("skips custom group names spliced with decoder garbage", async () => {
const home = await createHome();
const sessionId = "desktop-garbage-group";
const localSessionId = "local_33333333-3333-3333-3333-333333333333";
const groupId = "cg-44444444-4444-4444-4444-444444444444";
await writeProject({
home,
entries: [
const desktopGroupCases: Array<{
name: string;
sessionId: string;
localSessionId: string;
groupId: string;
expectedGroup?: string;
prompt?: string;
records: (
groupId: string,
localSessionId: string,
) => Array<{
sequence: number;
value: string | Buffer;
}>;
}> = [
{
name: "skips custom group names spliced with decoder garbage",
sessionId: "desktop-garbage-group",
localSessionId: "local_33333333-3333-3333-3333-333333333333",
groupId: "cg-44444444-4444-4444-4444-444444444444",
expectedGroup: "Release",
// Keep malformed control-byte records ahead of the valid record.
records: (groupId, localSessionId) => [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: "/work/openclaw",
isSidechain: false,
sequence: 1,
value:
`{"id":"${groupId}","name":"Rele\u0012)\fase"}` +
`{"id":"${groupId}","name":"Release"}` +
`{"code:${localSessionId}":"${groupId}"}`,
},
],
transcripts: { [sessionId]: [message(sessionId, "user", "garbage group prompt", 1)] },
});
await writeDesktopMetadata(home, "garbage-group", {
sessionId: localSessionId,
cliSessionId: sessionId,
cwd: "/work/openclaw",
title: "Desktop garbage group",
});
// Keep the control-byte guard as defense in depth for malformed decoded values.
await writeDesktopGroupStoreEntries(home, [
{
sequence: 1,
value:
`{"id":"${groupId}","name":"Rele\u0012)\fase"}` +
`{"id":"${groupId}","name":"Release"}` +
`{"code:${localSessionId}":"${groupId}"}`,
},
]);
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
sessions: [{ threadId: sessionId, customGroup: "Release", source: "claude-desktop" }],
});
});
it("uses the highest-sequence Claude Desktop custom group value", async () => {
const home = await createHome();
const sessionId = "desktop-newest-custom-group";
const localSessionId = "local_55555555-5555-5555-5555-555555555555";
const groupId = "cg-66666666-6666-6666-6666-666666666666";
await writeProject({
home,
entries: [
},
{
name: "uses the highest-sequence Claude Desktop custom group value",
sessionId: "desktop-newest-custom-group",
localSessionId: "local_55555555-5555-5555-5555-555555555555",
groupId: "cg-66666666-6666-6666-6666-666666666666",
expectedGroup: "New",
prompt: "newest group prompt",
records: (groupId, localSessionId) =>
["Old", "New"].map((group, index) => ({
sequence: index + 1,
value: `{"id":"${groupId}","name":"${group}"}{"code:${localSessionId}":"${groupId}"}`,
})),
},
{
name: "reads custom groups from a UTF-16 encoded Local Storage value",
sessionId: "desktop-utf16-group",
localSessionId: "local_77777777-7777-7777-7777-777777777777",
groupId: "cg-88888888-8888-8888-8888-888888888888",
expectedGroup: "Release",
// Chromium stores the entire JSON as UTF-16 once a value escapes Latin-1.
records: (groupId, localSessionId) => [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: "/work/openclaw",
isSidechain: false,
sequence: 1,
value: Buffer.from(
`{"id":"${groupId}","name":"Release"}{"code:${localSessionId}":"${groupId}"}`,
"utf16le",
),
},
],
transcripts: { [sessionId]: [message(sessionId, "user", "newest group prompt", 1)] },
});
await writeDesktopMetadata(home, "newest-custom-group", {
sessionId: localSessionId,
cliSessionId: sessionId,
cwd: "/work/openclaw",
title: "Desktop newest custom group",
});
await writeDesktopGroupStoreEntries(home, [
{
sequence: 1,
value: `{"id":"${groupId}","name":"Old"}{"code:${localSessionId}":"${groupId}"}`,
},
{
sequence: 2,
value: `{"id":"${groupId}","name":"New"}{"code:${localSessionId}":"${groupId}"}`,
},
]);
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
sessions: [{ threadId: sessionId, customGroup: "New", source: "claude-desktop" }],
});
});
it("reads custom groups from a UTF-16 encoded Local Storage value", async () => {
const home = await createHome();
const sessionId = "desktop-utf16-group";
const localSessionId = "local_77777777-7777-7777-7777-777777777777";
const groupId = "cg-88888888-8888-8888-8888-888888888888";
await writeProject({
home,
entries: [
},
{
name: "drops custom groups once a newer entry no longer carries them",
sessionId: "desktop-deleted-group",
localSessionId: "local_99999999-9999-9999-9999-999999999999",
groupId: "cg-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
// A newer empty store must shadow the older assignment.
records: (groupId, localSessionId) => [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: "/work/openclaw",
isSidechain: false,
sequence: 1,
value: `{"id":"${groupId}","name":"Release"}{"code:${localSessionId}":"${groupId}"}`,
},
{ sequence: 2, value: "{}" },
],
transcripts: { [sessionId]: [message(sessionId, "user", "utf16 group prompt", 1)] },
});
await writeDesktopMetadata(home, "utf16-group", {
sessionId: localSessionId,
cliSessionId: sessionId,
cwd: "/work/openclaw",
title: "Desktop utf16 group",
});
// Chromium switches a whole value to UTF-16 when any character escapes Latin-1,
// so the ASCII JSON arrives with interleaved NUL bytes.
const records = `{"id":"${groupId}","name":"Release"}{"code:${localSessionId}":"${groupId}"}`;
await writeDesktopGroupStoreEntries(home, [
{ sequence: 1, value: Buffer.from(records, "utf16le") },
]);
},
];
await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({
sessions: [{ threadId: sessionId, customGroup: "Release", source: "claude-desktop" }],
});
});
it("drops custom groups once a newer entry no longer carries them", async () => {
const home = await createHome();
const sessionId = "desktop-deleted-group";
const localSessionId = "local_99999999-9999-9999-9999-999999999999";
const groupId = "cg-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
await writeProject({
home,
entries: [
{
sessionId,
fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`),
projectPath: "/work/openclaw",
isSidechain: false,
},
],
transcripts: { [sessionId]: [message(sessionId, "user", "deleted group prompt", 1)] },
});
await writeDesktopMetadata(home, "deleted-group", {
sessionId: localSessionId,
cliSessionId: sessionId,
cwd: "/work/openclaw",
title: "Desktop deleted group",
});
// Removing the last custom group rewrites the store without any records; the older
// value must not win on sequence.
await writeDesktopGroupStoreEntries(home, [
{
sequence: 1,
value: `{"id":"${groupId}","name":"Release"}{"code:${localSessionId}":"${groupId}"}`,
},
{ sequence: 2, value: "{}" },
]);
const page = await listLocalClaudeSessionPage({}, home);
expect(page.sessions[0]).toMatchObject({ threadId: sessionId, source: "claude-desktop" });
expect(page.sessions[0]).not.toHaveProperty("customGroup");
});
it.each(desktopGroupCases)(
"$name",
async ({ sessionId, localSessionId, groupId, expectedGroup, prompt, records }) => {
const home = await createHome();
const metadataName = sessionId.replace(/^desktop-/, "");
await writeIndexedDesktopSession(home, {
sessionId,
localSessionId,
metadataName,
title: `Desktop ${metadataName.replaceAll("-", " ")}`,
prompt: prompt ?? `${metadataName.replaceAll("-", " ")} prompt`,
});
await writeDesktopGroupStoreEntries(home, records(groupId, localSessionId));
const page = await listLocalClaudeSessionPage({}, home);
expect(page.sessions[0]).toMatchObject({ threadId: sessionId, source: "claude-desktop" });
if (expectedGroup === undefined) {
expect(page.sessions[0]).not.toHaveProperty("customGroup");
} else {
expect(page.sessions[0]).toMatchObject({ customGroup: expectedGroup });
}
},
);
it("discovers CLI fallback transcripts and rejects sidechains, foreign entrypoints, and escapes", async () => {
const home = await createHome();
+76 -246
View File
@@ -135,6 +135,20 @@ function createMockGoogleLiveSession(): MockGoogleLiveSession {
};
}
type GoogleLiveBridgeParams = Parameters<
ReturnType<typeof buildGoogleRealtimeVoiceProvider>["createBridge"]
>[0];
function createGoogleLiveBridge(params: Partial<GoogleLiveBridgeParams> = {}) {
const { providerConfig, ...callbacks } = params;
return buildGoogleRealtimeVoiceProvider().createBridge({
providerConfig: { apiKey: "gemini-key", ...providerConfig },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
...callbacks,
});
}
describe("buildGoogleRealtimeVoiceProvider", () => {
beforeEach(() => {
envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
@@ -191,16 +205,12 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("uses Gemini 3.1 Live-compatible defaults", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
const bridge = createGoogleLiveBridge({
providerConfig: {
apiKey: "gemini-key",
enableAffectiveDialog: true,
thinkingBudget: 8_193,
},
tools: [createRealtimeTool("openclaw_agent_consult")],
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
expect(bridge.supportsToolResultContinuation).toBe(false);
@@ -381,9 +391,7 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("omits tool names that Google Live cannot accept", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
const bridge = createGoogleLiveBridge({
tools: [
createRealtimeTool("_lookup"),
createRealtimeTool("calendar.lookup:next"),
@@ -395,8 +403,6 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
createMalformedToolName(42),
createUnreadableToolName(),
],
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
await bridge.connect();
@@ -409,85 +415,39 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
);
});
it("omits zero temperature for native audio responses", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: {
apiKey: "gemini-key",
temperature: 0,
},
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
await bridge.connect();
expect(lastConnectParams().config).not.toHaveProperty("temperature");
});
it("drops malformed VAD timing values before connecting", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: {
apiKey: "gemini-key",
prefixPaddingMs: -1,
silenceDurationMs: 250.5,
},
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
await bridge.connect();
expect(lastConnectParams().config).not.toHaveProperty("realtimeInputConfig");
});
it("drops malformed thinking budgets before connecting", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: {
apiKey: "gemini-key",
thinkingBudget: 24_576.5,
},
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
await bridge.connect();
expect(lastConnectParams().config).not.toHaveProperty("thinkingConfig");
it.each([
{
name: "omits zero temperature for native audio responses",
config: { temperature: 0 },
omitted: "temperature",
},
{
name: "drops malformed VAD timing values before connecting",
config: { prefixPaddingMs: -1, silenceDurationMs: 250.5 },
omitted: "realtimeInputConfig",
},
{
name: "drops malformed thinking budgets before connecting",
config: { thinkingBudget: 24_576.5 },
omitted: "thinkingConfig",
},
])("$name", async ({ config, omitted }) => {
await createGoogleLiveBridge({ providerConfig: config }).connect();
expect(lastConnectParams().config).not.toHaveProperty(omitted);
});
it("passes Google Live dynamic thinking budget through", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
await createGoogleLiveBridge({
providerConfig: {
apiKey: "gemini-key",
model: "gemini-live-2.5-flash-preview",
thinkingBudget: -1,
},
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
await bridge.connect();
}).connect();
expect(lastConnectParams().config.thinkingConfig).toEqual({ thinkingBudget: -1 });
});
it("omits adaptive thinking budgets for Gemini 3.1 Live", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: {
apiKey: "gemini-key",
thinkingBudget: -1,
},
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
await bridge.connect();
await createGoogleLiveBridge({ providerConfig: { thinkingBudget: -1 } }).connect();
expect(lastConnectParams().config).not.toHaveProperty("thinkingConfig");
});
@@ -694,44 +654,28 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
});
it("rejects browser session expiry outside Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
const provider = buildGoogleRealtimeVoiceProvider();
it.each([
{
name: "rejects browser session expiry outside Date range",
now: 8_640_000_000_000_001,
},
{
name: "rejects browser session creation while the process clock is invalid",
now: Number.NaN,
},
])("$name", async ({ now }) => {
vi.spyOn(Date, "now").mockReturnValue(now);
await expect(
provider.createBrowserSession?.({
providerConfig: {
apiKey: "gemini-key",
},
}),
).rejects.toThrow("Google realtime browser session expiry is outside the supported Date range");
expect(createTokenMock).not.toHaveBeenCalled();
});
it("rejects browser session creation while the process clock is invalid", async () => {
vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
const provider = buildGoogleRealtimeVoiceProvider();
await expect(
provider.createBrowserSession?.({
providerConfig: {
apiKey: "gemini-key",
},
buildGoogleRealtimeVoiceProvider().createBrowserSession?.({
providerConfig: { apiKey: "gemini-key" },
}),
).rejects.toThrow("Google realtime browser session expiry is outside the supported Date range");
expect(createTokenMock).not.toHaveBeenCalled();
});
it("can opt out of Google Live session resumption and context compression", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: {
apiKey: "gemini-key",
contextWindowCompression: false,
sessionResumption: false,
},
onAudio: vi.fn(),
onClearAudio: vi.fn(),
const bridge = createGoogleLiveBridge({
providerConfig: { contextWindowCompression: false, sessionResumption: false },
});
await bridge.connect();
@@ -1165,14 +1109,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
it("does not finalize or merge a hypothesis across a fresh automatic reconnect", async () => {
vi.useFakeTimers();
const provider = buildGoogleRealtimeVoiceProvider();
const onTranscript = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onTranscript,
});
const bridge = createGoogleLiveBridge({ onTranscript });
await bridge.connect();
const firstSession = lastConnectParams().callbacks;
@@ -1196,14 +1134,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
it("keeps transcript fragments pending across a resumable reconnect", async () => {
vi.useFakeTimers();
const provider = buildGoogleRealtimeVoiceProvider();
const onTranscript = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onTranscript,
});
const bridge = createGoogleLiveBridge({ onTranscript });
await bridge.connect();
const firstSession = lastConnectParams().callbacks;
@@ -1230,16 +1162,9 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
it("flushes pending transcripts before closing after reconnect failures", async () => {
vi.useFakeTimers();
const provider = buildGoogleRealtimeVoiceProvider();
const onClose = vi.fn();
const onTranscript = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onClose,
onTranscript,
});
const bridge = createGoogleLiveBridge({ onClose, onTranscript });
await bridge.connect();
const firstSession = lastConnectParams().callbacks;
@@ -1579,12 +1504,7 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("marks the Google audio stream complete after sustained telephony silence", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key", silenceDurationMs: 60 },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
const bridge = createGoogleLiveBridge({ providerConfig: { silenceDurationMs: 60 } });
await bridge.connect();
lastConnectParams().callbacks.onopen();
@@ -1611,12 +1531,7 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("fuses telephony mu-law conversion into the Gemini 16 kHz PCM input frame", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
const bridge = createGoogleLiveBridge();
await bridge.connect();
lastConnectParams().callbacks.onopen();
@@ -1634,12 +1549,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("accepts PCM16 24 kHz audio without the telephony mu-law hop", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
const bridge = createGoogleLiveBridge({
audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ,
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
await bridge.connect();
@@ -1656,14 +1567,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("can disable automatic VAD for manual activity signaling experiments", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: {
apiKey: "gemini-key",
automaticActivityDetectionDisabled: true,
},
onAudio: vi.fn(),
onClearAudio: vi.fn(),
const bridge = createGoogleLiveBridge({
providerConfig: { automaticActivityDetectionDisabled: true },
});
await bridge.connect();
@@ -1675,12 +1580,7 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("sends Gemini 3.1 text prompts as realtime input", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
const bridge = createGoogleLiveBridge();
await bridge.connect();
lastConnectParams().callbacks.onopen();
@@ -1693,14 +1593,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("keeps ordered client turns for explicit Gemini 2.5 sessions", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: {
apiKey: "gemini-key",
model: "gemini-live-2.5-flash-preview",
},
onAudio: vi.fn(),
onClearAudio: vi.fn(),
const bridge = createGoogleLiveBridge({
providerConfig: { model: "gemini-live-2.5-flash-preview" },
});
await bridge.connect();
@@ -1857,14 +1751,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
it("emits one complete transcript after Google marks a transcription finished", async () => {
vi.useFakeTimers();
const provider = buildGoogleRealtimeVoiceProvider();
const onTranscript = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onTranscript,
});
const bridge = createGoogleLiveBridge({ onTranscript });
await bridge.connect();
const onmessage = lastConnectParams().callbacks.onmessage;
@@ -1883,14 +1771,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("honors a finish-only transcription message", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onTranscript = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onTranscript,
});
const bridge = createGoogleLiveBridge({ onTranscript });
await bridge.connect();
const onmessage = lastConnectParams().callbacks.onmessage;
@@ -1983,14 +1865,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("retains unordered transcript chunks until a protocol terminal or close", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onTranscript = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onTranscript,
});
const bridge = createGoogleLiveBridge({ onTranscript });
await bridge.connect();
const onmessage = lastConnectParams().callbacks.onmessage;
@@ -2016,14 +1892,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("flushes pending transcripts when the bridge closes", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onTranscript = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onTranscript,
});
const bridge = createGoogleLiveBridge({ onTranscript });
await bridge.connect();
lastConnectParams().callbacks.onmessage({
@@ -2035,13 +1905,9 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("closes the Live session when the final transcript callback throws", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const callbackError = new Error("transcript persistence failed");
const onError = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
const bridge = createGoogleLiveBridge({
onError,
onTranscript: vi.fn((_role, _text, isFinal) => {
if (isFinal) {
@@ -2061,13 +1927,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("reports provider-confirmed input interruption as barge-in", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onClearAudio = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio,
});
const bridge = createGoogleLiveBridge({ onClearAudio });
await bridge.connect();
lastConnectParams().callbacks.onmessage({
@@ -2079,14 +1940,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("forwards Live API tool calls and submits matching function responses", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onToolCall = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onToolCall,
});
const bridge = createGoogleLiveBridge({ onToolCall });
await bridge.connect();
lastConnectParams().callbacks.onmessage({
@@ -2225,14 +2080,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("keeps Google Live consult calls open after continuing tool responses", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({
providerConfig: {
apiKey: "gemini-key",
model: "gemini-live-2.5-flash-preview",
},
onAudio: vi.fn(),
onClearAudio: vi.fn(),
const bridge = createGoogleLiveBridge({
providerConfig: { model: "gemini-live-2.5-flash-preview" },
onToolCall: vi.fn(),
});
@@ -2277,15 +2126,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("keeps Gemini 3.1 consult calls pending after rejecting continuation", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onError = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onToolCall: vi.fn(),
onError,
});
const bridge = createGoogleLiveBridge({ onToolCall: vi.fn(), onError });
await bridge.connect();
lastConnectParams().callbacks.onmessage({
@@ -2319,14 +2161,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("does not send malformed Live API tool responses without a matching call name", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onError = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onError,
});
const bridge = createGoogleLiveBridge({ onError });
await bridge.connect();
@@ -2440,14 +2276,8 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
});
it("reports Google Live tool response send failures without losing the call name", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const onError = vi.fn();
const bridge = provider.createBridge({
providerConfig: { apiKey: "gemini-key" },
onAudio: vi.fn(),
onClearAudio: vi.fn(),
onError,
});
const bridge = createGoogleLiveBridge({ onError });
await bridge.connect();
lastConnectParams().callbacks.onmessage({
+122 -240
View File
@@ -388,6 +388,28 @@ function googleToolResultMessage(name: "screenshot" | "weather"): Record<string,
};
}
function buildGoogleToolResultParams(
content: Array<Record<string, unknown>>,
options: {
model?: Partial<Model<"google-generative-ai">>;
toolName?: string;
} = {},
) {
return buildGoogleGenerativeAiParams(buildGeminiModel(options.model), {
messages: [
googleToolCallAssistantTurn(),
{
role: "toolResult",
toolCallId: "call_1",
toolName: options.toolName ?? "lookup",
content,
isError: false,
timestamp: 1,
},
],
} as never);
}
describe("google transport stream", () => {
beforeAll(async () => {
({
@@ -2137,78 +2159,39 @@ describe("google transport stream", () => {
});
});
it("keeps a tool call's own Gemini thought signature before replay fallback", () => {
const model = buildGeminiModel({
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
});
const params = buildGoogleGenerativeAiParams(model, {
messages: [
googleToolCallAssistantTurn({ thoughtSignature: "Y2FsbF9zaWdfZmlyc3RfMQ==" }),
toolResultTurn(),
googleToolCallAssistantTurn({
timestamp: 2,
thoughtSignature: "Y2FsbF9zaWdfc2Vjb25kXzE=",
}),
],
} as never);
it.each([
{
name: "keeps a tool call's own Gemini thought signature before replay fallback",
firstSignature: "Y2FsbF9zaWdfZmlyc3RfMQ==",
secondSignature: "Y2FsbF9zaWdfc2Vjb25kXzE=",
},
{
name: "does not replay Gemini thought signatures from later turns",
firstSignature: undefined,
secondSignature: "Y2FsbF9zaWdfZnV0dXJlXzE=",
},
])("$name", ({ firstSignature, secondSignature }) => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel({ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }),
{
messages: [
googleToolCallAssistantTurn({ thoughtSignature: firstSignature }),
toolResultTurn(),
googleToolCallAssistantTurn({ timestamp: 2, thoughtSignature: secondSignature }),
],
} as never,
);
const modelTurns = params.contents.filter(isModelTurnWithParts);
expect(modelTurns).toHaveLength(2);
expect(modelTurns[0]).toMatchObject({
parts: [
{
thoughtSignature: "Y2FsbF9zaWdfZmlyc3RfMQ==",
functionCall: { name: "lookup", args: { q: "hello" } },
},
],
});
expect(modelTurns[1]).toMatchObject({
parts: [
{
thoughtSignature: "Y2FsbF9zaWdfc2Vjb25kXzE=",
functionCall: { name: "lookup", args: { q: "hello" } },
},
],
});
});
it("does not replay Gemini thought signatures from later turns", () => {
const model = buildGeminiModel({
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
});
const params = buildGoogleGenerativeAiParams(model, {
messages: [
googleToolCallAssistantTurn(),
toolResultTurn(),
googleToolCallAssistantTurn({
timestamp: 2,
thoughtSignature: "Y2FsbF9zaWdfZnV0dXJlXzE=",
}),
],
} as never);
const modelTurns = params.contents.filter(isModelTurnWithParts);
expect(modelTurns).toHaveLength(2);
expect(modelTurns[0]).toMatchObject({
parts: [
{
thoughtSignature: "skip_thought_signature_validator",
functionCall: { name: "lookup", args: { q: "hello" } },
},
],
});
expect(modelTurns[1]).toMatchObject({
parts: [
{
thoughtSignature: "Y2FsbF9zaWdfZnV0dXJlXzE=",
functionCall: { name: "lookup", args: { q: "hello" } },
},
],
});
for (const [index, thoughtSignature] of [
firstSignature ?? "skip_thought_signature_validator",
secondSignature,
].entries()) {
expect(modelTurns[index]).toMatchObject({
parts: [{ thoughtSignature, functionCall: { name: "lookup", args: { q: "hello" } } }],
});
}
});
it("does not re-attach replayed Gemini thought signatures to a different tool-call part", () => {
@@ -2315,37 +2298,21 @@ describe("google transport stream", () => {
);
});
it("replaces invalid Gemini tool-call sentinel signatures with the skip fallback", () => {
const model = buildGeminiModel({
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
});
const params = buildGoogleGenerativeAiParams(model, {
messages: [googleToolCallAssistantTurn({ thoughtSignature: "reasoning" })],
} as never);
const part = (params.contents[0] as { parts: Array<Record<string, unknown>> }).parts[0];
expect(part).toMatchObject({
thoughtSignature: "skip_thought_signature_validator",
functionCall: { name: "lookup", args: { q: "hello" } },
});
});
it("preserves the skip-validator fallback for unsigned Gemini tool-call replay", () => {
const model = buildGeminiModel({
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",
});
const params = buildGoogleGenerativeAiParams(model, {
messages: [
googleToolCallAssistantTurn({ thoughtSignature: "skip_thought_signature_validator" }),
],
} as never);
const part = (params.contents[0] as { parts: Array<Record<string, unknown>> }).parts[0];
expect(part).toMatchObject({
it.each([
{
name: "replaces invalid Gemini tool-call sentinel signatures with the skip fallback",
signature: "reasoning",
},
{
name: "preserves the skip-validator fallback for unsigned Gemini tool-call replay",
signature: "skip_thought_signature_validator",
},
])("$name", ({ signature }) => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel({ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }),
{ messages: [googleToolCallAssistantTurn({ thoughtSignature: signature })] } as never,
);
expect(getFirstModelTurn(params.contents).parts[0]).toMatchObject({
thoughtSignature: "skip_thought_signature_validator",
functionCall: { name: "lookup", args: { q: "hello" } },
});
@@ -2582,21 +2549,11 @@ describe("google transport stream", () => {
});
it("strips explicit thinkingBudget=0 but preserves includeThoughts for Gemini 2.5 Pro", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as never,
{
thinking: {
enabled: true,
budgetTokens: 0,
},
} as never,
const thinkingConfig = requireThinkingConfig(
requireGenerationConfig(
buildGeminiUserParams({}, { thinking: { enabled: true, budgetTokens: 0 } }),
),
);
const generationConfig = requireGenerationConfig(params);
const thinkingConfig = requireThinkingConfig(generationConfig);
expect(thinkingConfig.includeThoughts).toBe(true);
expect(thinkingConfig).not.toHaveProperty("thinkingBudget");
});
@@ -2608,17 +2565,9 @@ describe("google transport stream", () => {
] as const)(
"uses thinkingLevel instead of disabled thinkingBudget for %s defaults",
(id, level) => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel({ id }),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as never,
{
maxTokens: 128,
} as never,
const generationConfig = requireGenerationConfig(
buildGeminiUserParams({ id }, { maxTokens: 128 }),
);
const generationConfig = requireGenerationConfig(params);
const thinkingConfig = requireThinkingConfig(generationConfig);
expect(generationConfig.maxOutputTokens).toBe(128);
expect(thinkingConfig.thinkingLevel).toBe(level);
@@ -2834,25 +2783,13 @@ describe("google transport stream", () => {
});
it("serializes structured-only Google tool results before fallback", () => {
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
messages: [
googleToolCallAssistantTurn(),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "lookup",
content: [
{
type: "json",
value: { city: "Paris", temperatureC: 21 },
apiToken: "secret-token-123",
},
],
isError: false,
timestamp: 1,
},
],
} as never);
const params = buildGoogleToolResultParams([
{
type: "json",
value: { city: "Paris", temperatureC: 21 },
apiToken: "secret-token-123",
},
]);
const responseTurn = params.contents[1] as GoogleTestContentTurn;
const functionResponse = expectDefined(responseTurn.parts[0], "JSON tool response part")
@@ -2866,22 +2803,10 @@ describe("google transport stream", () => {
});
it("keeps explicit Google tool-result text before structured fallback", () => {
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
messages: [
googleToolCallAssistantTurn(),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "lookup",
content: [
{ type: "json", value: { ignored: true } },
{ type: "text", text: "explicit result" },
],
isError: false,
timestamp: 1,
},
],
} as never);
const params = buildGoogleToolResultParams([
{ type: "json", value: { ignored: true } },
{ type: "text", text: "explicit result" },
]);
expect(params.contents[1]).toMatchObject({
parts: [{ functionResponse: { response: { output: "explicit result" } } }],
@@ -2889,27 +2814,15 @@ describe("google transport stream", () => {
});
it("redacts opaque and binary structured Google tool-result fields", () => {
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
messages: [
googleToolCallAssistantTurn(),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "lookup",
content: [
{
type: "resource",
mimeType: "image/png",
data: "abcdef",
encrypted_content: "opaque",
text: "data:image/png;base64,abcdef",
},
],
isError: false,
timestamp: 1,
},
],
} as never);
const params = buildGoogleToolResultParams([
{
type: "resource",
mimeType: "image/png",
data: "abcdef",
encrypted_content: "opaque",
text: "data:image/png;base64,abcdef",
},
]);
const responseTurn = params.contents[1] as GoogleTestContentTurn;
const functionResponse = expectDefined(responseTurn.parts[0], "resource tool response part")
@@ -2923,39 +2836,27 @@ describe("google transport stream", () => {
});
it("uses shared structured redaction for Google tool-result fields", () => {
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
messages: [
googleToolCallAssistantTurn(),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "lookup",
content: [
{
type: "json",
privateKey: "leaked-private-key-value-12345",
private_key: "leaked-private-key-snake-12345",
key: "leaked-generic-key-value-12345",
keyMaterial: "leaked-key-material-value-12345",
jwt: "leaked-jwt-value-1234567890",
session: "leaked-session-value-123456",
code: "code-value-1234567890",
error: { code: "ERR_VISIBLE_GOOGLE_CODE" },
oauth: { code: "OPAQUEGOOGLECODE1234567890" },
providerError: { error: { code: "ERR_VISIBLE_PROVIDER_GOOGLE_CODE" } },
signature: "leaked-signature-value-12345",
cookie: "leaked-cookie-value-123456",
"set-cookie": "leaked-set-cookie-value-12345",
paymentCredential: "leaked-payment-credential-12345",
cardNumber: "41111111111111112222",
visible: "safe-value",
},
],
isError: false,
timestamp: 1,
},
],
} as never);
const params = buildGoogleToolResultParams([
{
type: "json",
privateKey: "leaked-private-key-value-12345",
private_key: "leaked-private-key-snake-12345",
key: "leaked-generic-key-value-12345",
keyMaterial: "leaked-key-material-value-12345",
jwt: "leaked-jwt-value-1234567890",
session: "leaked-session-value-123456",
code: "code-value-1234567890",
error: { code: "ERR_VISIBLE_GOOGLE_CODE" },
oauth: { code: "OPAQUEGOOGLECODE1234567890" },
providerError: { error: { code: "ERR_VISIBLE_PROVIDER_GOOGLE_CODE" } },
signature: "leaked-signature-value-12345",
cookie: "leaked-cookie-value-123456",
"set-cookie": "leaked-set-cookie-value-12345",
paymentCredential: "leaked-payment-credential-12345",
cardNumber: "41111111111111112222",
visible: "safe-value",
},
]);
const responseTurn = params.contents[1] as GoogleTestContentTurn;
const functionResponse = expectDefined(responseTurn.parts[0], "redacted tool response part")
@@ -2984,19 +2885,9 @@ describe("google transport stream", () => {
});
it("keeps Google media-only tool results on media placeholders", () => {
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
messages: [
googleToolCallAssistantTurn(),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "lookup",
content: [{ type: "audio", mimeType: "audio/wav", data: "wav-bytes" }],
isError: false,
timestamp: 1,
},
],
} as never);
const params = buildGoogleToolResultParams([
{ type: "audio", mimeType: "audio/wav", data: "wav-bytes" },
]);
expect(params.contents[1]).toMatchObject({
parts: [{ functionResponse: { response: { output: "(see attached audio)" } } }],
@@ -3004,21 +2895,12 @@ describe("google transport stream", () => {
});
it("does not emit inline data or media placeholders for payload-less tool images", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel({ id: "gemini-3-flash", input: ["text", "image"] }),
const params = buildGoogleToolResultParams(
[{ type: "image", mimeType: "image/png", data: "" }],
{
messages: [
googleToolCallAssistantTurn(),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "screenshot",
content: [{ type: "image", mimeType: "image/png", data: "" }],
isError: false,
timestamp: 1,
},
],
} as never,
model: { id: "gemini-3-flash", input: ["text", "image"] },
toolName: "screenshot",
},
);
const serialized = JSON.stringify(params.contents);
+76 -113
View File
@@ -606,27 +606,22 @@ describe("lmstudio-models", () => {
expect(release).toHaveBeenCalledOnce();
});
it("reports malformed model list JSON with an owned error", async () => {
const fetchMock = vi.fn(async () => malformedJsonResponse());
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
fetchImpl: asFetch(fetchMock),
});
expect(result.reachable).toBe(false);
expect((result.error as Error).message).toBe("LM Studio model list: malformed JSON response");
});
it("reports wrong-shaped model list payloads with owned errors", async () => {
for (const payload of [[], { models: {} }, { models: [null] }]) {
const fetchMock = vi.fn(async () => jsonResponse(payload));
it.each([
{
name: "reports malformed model list JSON with an owned error",
responses: () => [malformedJsonResponse()],
},
{
name: "reports wrong-shaped model list payloads with owned errors",
responses: () =>
[[], { models: {} }, { models: [null] }].map((payload) => jsonResponse(payload)),
},
])("$name", async ({ responses }) => {
for (const response of responses()) {
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
fetchImpl: asFetch(fetchMock),
fetchImpl: asFetch(vi.fn(async () => response)),
});
expect(result.reachable).toBe(false);
expect((result.error as Error).message).toBe("LM Studio model list: malformed JSON response");
}
@@ -720,42 +715,81 @@ describe("lmstudio-models", () => {
expect(calledUrls).not.toContain("http://localhost:1234/api/v1/models/load");
});
it("reloads model when requested context length exceeds the loaded window", async () => {
const fetchMock = createModelLoadFetchMock({
it.each([
{
name: "reloads model when requested context length exceeds the loaded window",
loadedContextLength: 4096,
maxContextLength: 32768,
});
vi.stubGlobal("fetch", asFetch(fetchMock));
requestedContextLength: 8192,
expectedContextLength: 8192,
},
{
name: "reloads model to the clamped default target when already loaded below the default window",
loadedContextLength: 4096,
maxContextLength: 32768,
expectedContextLength: 32768,
},
{
name: "uses requested context length when provided for model load",
maxContextLength: 32768,
requestedContextLength: 8192,
expectedContextLength: 8192,
},
{
name: "omits malformed context lengths before loading models",
loadedContextLength: 4096.5,
maxContextLength: 32768.5,
requestedContextLength: 8192.5,
expectedContextLength: LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH,
},
])(
"$name",
async ({
loadedContextLength,
maxContextLength,
requestedContextLength,
expectedContextLength,
}) => {
const fetchMock = createModelLoadFetchMock({ loadedContextLength, maxContextLength });
vi.stubGlobal("fetch", asFetch(fetchMock));
await expect(
ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
...(requestedContextLength === undefined ? {} : { requestedContextLength }),
}),
).resolves.toBe("qwen3-8b-instruct");
expect(fetchMock).toHaveBeenCalledTimes(2);
expectLoadContextLength(fetchMock, expectedContextLength);
},
);
await expect(
ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
requestedContextLength: 8192,
}),
).resolves.toBe("qwen3-8b-instruct");
expect(fetchMock).toHaveBeenCalledTimes(2);
expectLoadContextLength(fetchMock, 8192);
});
it("loads the canonical model key when the requested key is an advertised variant", async () => {
const canonicalKey = "gemma-4-e4b-it-ultra-uncensored-heretic";
const variantKey = `${canonicalKey}@q4_k_m`;
it.each([
{
name: "loads the canonical model key when the requested key is an advertised variant",
canonicalKey: "gemma-4-e4b-it-ultra-uncensored-heretic",
requestedKey: "gemma-4-e4b-it-ultra-uncensored-heretic@q4_k_m",
advertisedVariant: "gemma-4-e4b-it-ultra-uncensored-heretic@q4_k_m",
},
{
name: "preserves a suffixed key when LM Studio advertises it as the model key",
canonicalKey: "local/special-model@q4_k_m",
requestedKey: "local/special-model@q4_k_m",
advertisedVariant: "local/special-model@q8_0",
},
])("$name", async ({ canonicalKey, requestedKey, advertisedVariant }) => {
const fetchMock = createModelLoadFetchMock({
key: canonicalKey,
variants: [variantKey],
selectedVariant: variantKey,
variants: [advertisedVariant],
selectedVariant: advertisedVariant,
});
vi.stubGlobal("fetch", asFetch(fetchMock));
await expect(
ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: variantKey,
modelKey: requestedKey,
}),
).resolves.toBe(canonicalKey);
expect(fetchMock).toHaveBeenCalledTimes(2);
expectLoadModelKey(fetchMock, canonicalKey);
});
@@ -793,26 +827,6 @@ describe("lmstudio-models", () => {
expect(error).toMatchObject({ resolvedModelKey: canonicalKey });
});
it("preserves a suffixed key when LM Studio advertises it as the model key", async () => {
const suffixedKey = "local/special-model@q4_k_m";
const fetchMock = createModelLoadFetchMock({
key: suffixedKey,
variants: ["local/special-model@q8_0"],
selectedVariant: "local/special-model@q8_0",
});
vi.stubGlobal("fetch", asFetch(fetchMock));
await expect(
ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: suffixedKey,
}),
).resolves.toBe(suffixedKey);
expect(fetchMock).toHaveBeenCalledTimes(2);
expectLoadModelKey(fetchMock, suffixedKey);
});
it("reports malformed model load JSON with an owned error", async () => {
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
@@ -911,24 +925,6 @@ describe("lmstudio-models", () => {
expect(textSpy).not.toHaveBeenCalled();
});
it("reloads model to the clamped default target when already loaded below the default window", async () => {
const fetchMock = createModelLoadFetchMock({
loadedContextLength: 4096,
maxContextLength: 32768,
});
vi.stubGlobal("fetch", asFetch(fetchMock));
await expect(
ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
}),
).resolves.toBe("qwen3-8b-instruct");
expect(fetchMock).toHaveBeenCalledTimes(2);
expectLoadContextLength(fetchMock, 32768);
});
it("loads model with clamped context length and merged headers", async () => {
const fetchMock = createModelLoadFetchMock({ maxContextLength: 32768 });
vi.stubGlobal("fetch", asFetch(fetchMock));
@@ -969,39 +965,6 @@ describe("lmstudio-models", () => {
expect(loadBody.context_length).not.toBe(LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH);
});
it("uses requested context length when provided for model load", async () => {
const fetchMock = createModelLoadFetchMock({ maxContextLength: 32768 });
vi.stubGlobal("fetch", asFetch(fetchMock));
await expect(
ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
requestedContextLength: 8192,
}),
).resolves.toBe("qwen3-8b-instruct");
expectLoadContextLength(fetchMock, 8192);
});
it("omits malformed context lengths before loading models", async () => {
const fetchMock = createModelLoadFetchMock({
loadedContextLength: 4096.5,
maxContextLength: 32768.5,
});
vi.stubGlobal("fetch", asFetch(fetchMock));
await expect(
ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
requestedContextLength: 8192.5,
}),
).resolves.toBe("qwen3-8b-instruct");
expectLoadContextLength(fetchMock, LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH);
});
it("throws when model discovery fails", async () => {
const fetchMock = vi.fn(async () => ({
ok: false,