Files
opencode-antigravity-auth/src/plugin/request.test.ts
tctinh 16f4bb07a1 feat: add E2E testing scripts and simplify Gemini Flash model config
- Add test-models.ts for validating all supported model endpoints
- Add test-regression.ts for multi-turn regression testing (Issue #50)
- Consolidate Gemini 3 Flash variants (low/medium/high) into single model
- Fix schema structure by flattening nested signature_cache properties
- Extract streaming transformer utilities to dedicated module
2025-12-28 00:04:57 +07:00

686 lines
24 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
prepareAntigravityRequest,
getPluginSessionId,
isGenerativeLanguageRequest,
__testExports,
} from "./request";
import type { SignatureStore, ThoughtBuffer, StreamingCallbacks, StreamingOptions } from "./core/streaming/types";
const {
buildSignatureSessionKey,
hashConversationSeed,
extractTextFromContent,
extractConversationSeedFromMessages,
extractConversationSeedFromContents,
resolveProjectKey,
isGeminiToolUsePart,
isGeminiThinkingPart,
ensureThoughtSignature,
hasSignedThinkingPart,
hasToolUseInContents,
hasSignedThinkingInContents,
hasToolUseInMessages,
hasSignedThinkingInMessages,
generateSyntheticProjectId,
MIN_SIGNATURE_LENGTH,
transformStreamingPayload,
createStreamingTransformer,
transformSseLine,
} = __testExports;
function createMockSignatureStore(): SignatureStore {
const store = new Map<string, { text: string; signature: string }>();
return {
get: (key: string) => store.get(key),
set: (key: string, value: { text: string; signature: string }) => store.set(key, value),
has: (key: string) => store.has(key),
delete: (key: string) => store.delete(key),
};
}
function createMockThoughtBuffer(): ThoughtBuffer {
const buffer = new Map<number, string>();
return {
get: (idx: number) => buffer.get(idx),
set: (idx: number, text: string) => buffer.set(idx, text),
clear: () => buffer.clear(),
};
}
const defaultCallbacks: StreamingCallbacks = {};
const defaultOptions: StreamingOptions = {};
const defaultDebugState = { injected: false };
describe("request.ts", () => {
describe("getPluginSessionId", () => {
it("returns consistent session ID across calls", () => {
const id1 = getPluginSessionId();
const id2 = getPluginSessionId();
expect(id1).toBe(id2);
expect(id1).toBeTruthy();
});
});
describe("isGenerativeLanguageRequest", () => {
it("returns true for generativelanguage.googleapis.com URLs", () => {
expect(isGenerativeLanguageRequest("https://generativelanguage.googleapis.com/v1/models")).toBe(true);
});
it("returns false for other URLs", () => {
expect(isGenerativeLanguageRequest("https://api.anthropic.com/v1/messages")).toBe(false);
});
it("returns false for non-string inputs", () => {
expect(isGenerativeLanguageRequest({} as any)).toBe(false);
expect(isGenerativeLanguageRequest(new Request("https://example.com"))).toBe(false);
});
});
describe("buildSignatureSessionKey", () => {
it("builds key from sessionId, model, project, and conversation", () => {
const key = buildSignatureSessionKey("session-1", "claude-3", "conv-456", "proj-123");
expect(key).toBe("session-1:claude-3:proj-123:conv-456");
});
it("uses defaults for missing optional params", () => {
expect(buildSignatureSessionKey("s1", undefined, undefined, undefined)).toBe("s1:unknown:default:default");
expect(buildSignatureSessionKey("s1", "model", undefined, undefined)).toBe("s1:model:default:default");
});
it("handles empty strings as defaults", () => {
expect(buildSignatureSessionKey("s1", "", "", "")).toBe("s1:unknown:default:default");
});
});
describe("hashConversationSeed", () => {
it("returns consistent hash for same input", () => {
const hash1 = hashConversationSeed("test-seed");
const hash2 = hashConversationSeed("test-seed");
expect(hash1).toBe(hash2);
});
it("returns different hash for different inputs", () => {
const hash1 = hashConversationSeed("seed-1");
const hash2 = hashConversationSeed("seed-2");
expect(hash1).not.toBe(hash2);
});
it("handles empty string", () => {
const hash = hashConversationSeed("");
expect(hash).toBeTruthy();
});
});
describe("extractTextFromContent", () => {
it("extracts text from string content", () => {
expect(extractTextFromContent("hello world")).toBe("hello world");
});
it("extracts first text from content array with text blocks", () => {
const content = [
{ type: "text", text: "hello" },
{ type: "text", text: "world" },
];
expect(extractTextFromContent(content)).toBe("hello");
});
it("returns empty string for non-text blocks", () => {
const content = [{ type: "image", source: {} }];
expect(extractTextFromContent(content)).toBe("");
});
it("returns first text block only (not concatenated)", () => {
const content = [
{ type: "text", text: "before" },
{ type: "image", source: {} },
{ type: "text", text: "after" },
];
expect(extractTextFromContent(content)).toBe("before");
});
it("returns empty string for null/undefined", () => {
expect(extractTextFromContent(null)).toBe("");
expect(extractTextFromContent(undefined)).toBe("");
});
});
describe("extractConversationSeedFromMessages", () => {
it("extracts seed from first user message", () => {
const messages = [
{ role: "user", content: "first message" },
{ role: "assistant", content: "response" },
];
const seed = extractConversationSeedFromMessages(messages);
expect(seed).toContain("first message");
});
it("returns empty string when no user messages", () => {
const messages = [{ role: "assistant", content: "response" }];
expect(extractConversationSeedFromMessages(messages)).toBe("");
});
it("handles empty messages array", () => {
expect(extractConversationSeedFromMessages([])).toBe("");
});
});
describe("extractConversationSeedFromContents", () => {
it("extracts seed from first user content", () => {
const contents = [
{ role: "user", parts: [{ text: "hello" }] },
{ role: "model", parts: [{ text: "hi" }] },
];
const seed = extractConversationSeedFromContents(contents);
expect(seed).toContain("hello");
});
it("returns empty string when no user content", () => {
const contents = [{ role: "model", parts: [{ text: "hi" }] }];
expect(extractConversationSeedFromContents(contents)).toBe("");
});
});
describe("resolveProjectKey", () => {
it("returns candidate if it is a string", () => {
expect(resolveProjectKey("my-project")).toBe("my-project");
});
it("returns fallback if candidate is not a string", () => {
expect(resolveProjectKey(null, "fallback")).toBe("fallback");
expect(resolveProjectKey(undefined, "fallback")).toBe("fallback");
expect(resolveProjectKey({}, "fallback")).toBe("fallback");
});
it("returns undefined if no valid candidate or fallback", () => {
expect(resolveProjectKey(null)).toBeUndefined();
expect(resolveProjectKey(undefined)).toBeUndefined();
});
});
describe("isGeminiToolUsePart", () => {
it("returns true for functionCall parts", () => {
expect(isGeminiToolUsePart({ functionCall: { name: "test" } })).toBe(true);
});
it("returns false for non-functionCall parts", () => {
expect(isGeminiToolUsePart({ text: "hello" })).toBe(false);
expect(isGeminiToolUsePart({ thought: true })).toBe(false);
});
it("returns false for null/undefined", () => {
expect(isGeminiToolUsePart(null)).toBe(false);
expect(isGeminiToolUsePart(undefined)).toBe(false);
});
});
describe("isGeminiThinkingPart", () => {
it("returns true for thought:true parts", () => {
expect(isGeminiThinkingPart({ thought: true, text: "thinking..." })).toBe(true);
});
it("returns false for thought:false parts", () => {
expect(isGeminiThinkingPart({ thought: false, text: "not thinking" })).toBe(false);
});
it("returns false for parts without thought property", () => {
expect(isGeminiThinkingPart({ text: "hello" })).toBe(false);
});
});
describe("ensureThoughtSignature", () => {
it("returns part unchanged when no cached signature exists", () => {
const part = { thought: true, text: "thinking..." };
const result = ensureThoughtSignature(part, "no-cache-session");
expect(result).toEqual(part);
});
it("preserves existing thoughtSignature", () => {
const existingSignature = "a".repeat(MIN_SIGNATURE_LENGTH + 10);
const part = { thought: true, text: "thinking...", thoughtSignature: existingSignature };
const result = ensureThoughtSignature(part, "session-key");
expect(result.thoughtSignature).toBe(existingSignature);
});
it("does not modify non-thinking parts", () => {
const part = { text: "regular text" };
const result = ensureThoughtSignature(part, "session-key");
expect(result.thoughtSignature).toBeUndefined();
});
it("returns null/undefined inputs unchanged", () => {
expect(ensureThoughtSignature(null, "key")).toBeNull();
expect(ensureThoughtSignature(undefined, "key")).toBeUndefined();
});
it("returns non-object inputs unchanged", () => {
expect(ensureThoughtSignature("string", "key")).toBe("string");
expect(ensureThoughtSignature(123, "key")).toBe(123);
});
});
describe("hasSignedThinkingPart", () => {
it("returns true for part with valid thoughtSignature", () => {
const part = { thought: true, thoughtSignature: "a".repeat(MIN_SIGNATURE_LENGTH) };
expect(hasSignedThinkingPart(part)).toBe(true);
});
it("returns true for type:thinking with valid signature field", () => {
const part = { type: "thinking", thinking: "...", signature: "a".repeat(MIN_SIGNATURE_LENGTH) };
expect(hasSignedThinkingPart(part)).toBe(true);
});
it("returns true for type:reasoning with valid signature field", () => {
const part = { type: "reasoning", signature: "a".repeat(MIN_SIGNATURE_LENGTH) };
expect(hasSignedThinkingPart(part)).toBe(true);
});
it("returns false for part with short signature", () => {
const part = { thought: true, thoughtSignature: "short" };
expect(hasSignedThinkingPart(part)).toBe(false);
});
it("returns false for part without signature", () => {
const part = { thought: true, text: "no signature" };
expect(hasSignedThinkingPart(part)).toBe(false);
});
});
describe("hasToolUseInContents", () => {
it("returns true when contents have functionCall", () => {
const contents = [
{ role: "model", parts: [{ functionCall: { name: "test" } }] },
];
expect(hasToolUseInContents(contents)).toBe(true);
});
it("returns false when no functionCall present", () => {
const contents = [
{ role: "model", parts: [{ text: "hello" }] },
];
expect(hasToolUseInContents(contents)).toBe(false);
});
it("handles empty contents", () => {
expect(hasToolUseInContents([])).toBe(false);
});
});
describe("hasSignedThinkingInContents", () => {
it("returns true when contents have signed thinking", () => {
const contents = [
{
role: "model",
parts: [{ thought: true, thoughtSignature: "a".repeat(MIN_SIGNATURE_LENGTH) }],
},
];
expect(hasSignedThinkingInContents(contents)).toBe(true);
});
it("returns false when no signed thinking present", () => {
const contents = [
{ role: "model", parts: [{ thought: true, text: "unsigned" }] },
];
expect(hasSignedThinkingInContents(contents)).toBe(false);
});
});
describe("hasToolUseInMessages", () => {
it("returns true when messages have tool_use blocks", () => {
const messages = [
{ role: "assistant", content: [{ type: "tool_use", id: "123", name: "test" }] },
];
expect(hasToolUseInMessages(messages)).toBe(true);
});
it("returns false when no tool_use blocks", () => {
const messages = [
{ role: "assistant", content: [{ type: "text", text: "hello" }] },
];
expect(hasToolUseInMessages(messages)).toBe(false);
});
it("handles string content", () => {
const messages = [{ role: "assistant", content: "just text" }];
expect(hasToolUseInMessages(messages)).toBe(false);
});
});
describe("hasSignedThinkingInMessages", () => {
it("returns true when messages have signed thinking blocks", () => {
const messages = [
{
role: "assistant",
content: [{ type: "thinking", thinking: "...", signature: "a".repeat(MIN_SIGNATURE_LENGTH) }],
},
];
expect(hasSignedThinkingInMessages(messages)).toBe(true);
});
it("returns false when thinking blocks are unsigned", () => {
const messages = [
{ role: "assistant", content: [{ type: "thinking", thinking: "no sig" }] },
];
expect(hasSignedThinkingInMessages(messages)).toBe(false);
});
});
describe("generateSyntheticProjectId", () => {
it("generates a string in expected format", () => {
const id = generateSyntheticProjectId();
expect(id).toMatch(/^[a-z]+-[a-z]+-[a-z0-9]{5}$/);
});
it("generates unique IDs on each call", () => {
const ids = new Set<string>();
for (let i = 0; i < 10; i++) {
ids.add(generateSyntheticProjectId());
}
expect(ids.size).toBe(10);
});
});
describe("MIN_SIGNATURE_LENGTH", () => {
it("is 50", () => {
expect(MIN_SIGNATURE_LENGTH).toBe(50);
});
});
describe("transformSseLine", () => {
const callTransformSseLine = (line: string) => {
const store = createMockSignatureStore();
const buffer = createMockThoughtBuffer();
return transformSseLine(line, store, buffer, defaultCallbacks, defaultOptions, { ...defaultDebugState });
};
it("returns empty lines unchanged", () => {
expect(callTransformSseLine("")).toBe("");
expect(callTransformSseLine(" ")).toBe(" ");
});
it("returns non-data lines unchanged", () => {
expect(callTransformSseLine("event: message")).toBe("event: message");
expect(callTransformSseLine(": heartbeat")).toBe(": heartbeat");
});
it("handles data: [DONE] unchanged", () => {
expect(callTransformSseLine("data: [DONE]")).toBe("data: [DONE]");
});
it("handles invalid JSON gracefully", () => {
expect(callTransformSseLine("data: not-json")).toBe("data: not-json");
expect(callTransformSseLine("data: {invalid}")).toBe("data: {invalid}");
});
it("passes through valid JSON without thinking parts", () => {
const payload = { candidates: [{ content: { parts: [{ text: "hello" }] } }] };
const line = `data: ${JSON.stringify(payload)}`;
const result = callTransformSseLine(line);
expect(result).toContain("data:");
expect(result).toContain("hello");
});
it("transforms thinking parts in streaming data", () => {
const payload = {
candidates: [{
content: {
parts: [{ thought: true, text: "reasoning..." }]
}
}]
};
const line = `data: ${JSON.stringify(payload)}`;
const result = callTransformSseLine(line);
expect(result).toContain("data:");
});
});
describe("transformStreamingPayload", () => {
it("handles empty string", () => {
expect(transformStreamingPayload("")).toBe("");
});
it("handles single line without data prefix", () => {
expect(transformStreamingPayload("event: ping")).toBe("event: ping");
});
it("handles multiple lines", () => {
const input = "event: message\ndata: [DONE]\n";
const result = transformStreamingPayload(input);
expect(result).toContain("event: message");
expect(result).toContain("data: [DONE]");
});
it("preserves line structure", () => {
const input = "line1\nline2\nline3";
const result = transformStreamingPayload(input);
const lines = result.split("\n");
expect(lines.length).toBe(3);
});
});
describe("createStreamingTransformer", () => {
it("returns a TransformStream", () => {
const store = createMockSignatureStore();
const transformer = createStreamingTransformer(store, defaultCallbacks);
expect(transformer).toBeInstanceOf(TransformStream);
expect(transformer.readable).toBeDefined();
expect(transformer.writable).toBeDefined();
});
it("accepts optional signatureSessionKey", () => {
const store = createMockSignatureStore();
const transformer = createStreamingTransformer(store, defaultCallbacks, { signatureSessionKey: "session-key" });
expect(transformer).toBeInstanceOf(TransformStream);
});
it("accepts optional debugText", () => {
const store = createMockSignatureStore();
const transformer = createStreamingTransformer(store, defaultCallbacks, { signatureSessionKey: "session-key", debugText: "debug info" });
expect(transformer).toBeInstanceOf(TransformStream);
});
it("accepts cacheSignatures flag", () => {
const store = createMockSignatureStore();
const transformer = createStreamingTransformer(store, defaultCallbacks, { signatureSessionKey: "session-key", cacheSignatures: true });
expect(transformer).toBeInstanceOf(TransformStream);
});
it("processes chunks through the stream", async () => {
const store = createMockSignatureStore();
const transformer = createStreamingTransformer(store, defaultCallbacks);
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const input = encoder.encode("data: [DONE]\n");
const outputChunks: Uint8Array[] = [];
const writer = transformer.writable.getWriter();
const reader = transformer.readable.getReader();
const readPromise = (async () => {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) outputChunks.push(value);
}
})();
await writer.write(input);
await writer.close();
await readPromise;
const output = outputChunks.map(chunk => decoder.decode(chunk)).join("");
expect(output).toContain("[DONE]");
});
});
describe("prepareAntigravityRequest", () => {
const mockAccessToken = "test-token";
const mockProjectId = "test-project";
it("returns unchanged request for non-generative-language URLs", () => {
const result = prepareAntigravityRequest(
"https://example.com/api",
{ method: "POST" },
mockAccessToken,
mockProjectId
);
expect(result.streaming).toBe(false);
expect(result.request).toBe("https://example.com/api");
});
it("returns unchanged request for URLs without model pattern", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1/models",
{ method: "POST" },
mockAccessToken,
mockProjectId
);
expect(result.streaming).toBe(false);
});
it("detects streaming from generateStreamContent action", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:streamGenerateContent",
{ method: "POST", body: JSON.stringify({ contents: [] }) },
mockAccessToken,
mockProjectId
);
expect(result.streaming).toBe(true);
});
it("detects non-streaming from generateContent action", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
{ method: "POST", body: JSON.stringify({ contents: [] }) },
mockAccessToken,
mockProjectId
);
expect(result.streaming).toBe(false);
});
it("sets Authorization header with Bearer token", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
{ method: "POST", body: JSON.stringify({ contents: [] }) },
mockAccessToken,
mockProjectId
);
const headers = result.init.headers as Headers;
expect(headers.get("Authorization")).toBe("Bearer test-token");
});
it("removes x-api-key header", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
{ method: "POST", body: JSON.stringify({ contents: [] }), headers: { "x-api-key": "old-key" } },
mockAccessToken,
mockProjectId
);
const headers = result.init.headers as Headers;
expect(headers.get("x-api-key")).toBeNull();
});
it("identifies Claude models correctly", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/claude-sonnet-4-20250514:generateContent",
{ method: "POST", body: JSON.stringify({ contents: [] }) },
mockAccessToken,
mockProjectId
);
expect(result.effectiveModel).toContain("claude");
});
it("identifies Gemini models correctly", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent",
{ method: "POST", body: JSON.stringify({ contents: [] }) },
mockAccessToken,
mockProjectId
);
expect(result.effectiveModel).toContain("gemini");
});
it("uses custom endpoint override", () => {
const customEndpoint = "https://custom.api.com";
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
{ method: "POST", body: JSON.stringify({ contents: [] }) },
mockAccessToken,
mockProjectId,
customEndpoint
);
expect(result.endpoint).toContain(customEndpoint);
});
it("handles wrapped Antigravity body format", () => {
const wrappedBody = {
project: "my-project",
request: { contents: [{ parts: [{ text: "Hello" }] }] }
};
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
{ method: "POST", body: JSON.stringify(wrappedBody) },
mockAccessToken,
mockProjectId
);
expect(result.streaming).toBe(false);
});
it("handles unwrapped body format", () => {
const unwrappedBody = {
contents: [{ parts: [{ text: "Hello" }] }]
};
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
{ method: "POST", body: JSON.stringify(unwrappedBody) },
mockAccessToken,
mockProjectId
);
expect(result.streaming).toBe(false);
});
it("returns requestedModel matching URL model", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
{ method: "POST", body: JSON.stringify({ contents: [] }) },
mockAccessToken,
mockProjectId
);
expect(result.requestedModel).toBe("gemini-2.5-flash");
});
it("handles empty body gracefully", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
{ method: "POST", body: JSON.stringify({}) },
mockAccessToken,
mockProjectId
);
expect(result.streaming).toBe(false);
});
it("handles minimal valid JSON body", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
{ method: "POST", body: JSON.stringify({ contents: [] }) },
mockAccessToken,
mockProjectId
);
expect(result.streaming).toBe(false);
});
it("preserves headerStyle in response", () => {
const result = prepareAntigravityRequest(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent",
{ method: "POST", body: JSON.stringify({ contents: [] }) },
mockAccessToken,
mockProjectId,
undefined,
"gemini-cli"
);
expect(result.headerStyle).toBe("gemini-cli");
});
});
});