diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index 4e6ea3cca71f..754792ddf8ac 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -74,6 +74,16 @@ function levelIds(profile: unknown): Array { 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; + 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); diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index fdb2e33110dd..64bee44d9916 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -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; + }, +): Promise { + 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(); diff --git a/extensions/google/realtime-voice-provider.test.ts b/extensions/google/realtime-voice-provider.test.ts index 7f148c973261..0615e6cedca5 100644 --- a/extensions/google/realtime-voice-provider.test.ts +++ b/extensions/google/realtime-voice-provider.test.ts @@ -135,6 +135,20 @@ function createMockGoogleLiveSession(): MockGoogleLiveSession { }; } +type GoogleLiveBridgeParams = Parameters< + ReturnType["createBridge"] +>[0]; + +function createGoogleLiveBridge(params: Partial = {}) { + 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({ diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index ee7854f26f57..9c35023bfd0a 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -388,6 +388,28 @@ function googleToolResultMessage(name: "screenshot" | "weather"): Record>, + options: { + model?: Partial>; + 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> }).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> }).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); diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts index 9c4e9e4196be..aa91ee572ba9 100644 --- a/extensions/lmstudio/src/models.test.ts +++ b/extensions/lmstudio/src/models.test.ts @@ -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,