mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(providers): consolidate contract fixtures (#114424)
This commit is contained in:
committed by
GitHub
parent
2e6c2bba19
commit
00194139ba
+116
-190
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user