Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/drift-report-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,10 @@ const PROVIDER_MAP: Record<string, ProviderMapping> = {
builderFunctions: [
"buildInteractionsTextResponse",
"buildInteractionsToolCallResponse",
"buildInteractionsContentWithToolCallsResponse",
"buildInteractionsTextSSEEvents",
"buildInteractionsToolCallSSEEvents",
"buildInteractionsContentWithToolCallsSSEEvents",
],
typesFile: null,
},
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/drift-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ const PROVIDER_MAP: Record<string, ProviderMapping> = {
typesFile: null,
sdkShapesFile: "src/__tests__/drift/sdk-shapes.ts",
},
"Gemini Interactions": {
builderFile: "src/gemini-interactions.ts",
builderFunctions: [
"buildInteractionsTextResponse",
"buildInteractionsToolCallResponse",
"buildInteractionsContentWithToolCallsResponse",
"buildInteractionsTextSSEEvents",
"buildInteractionsToolCallSSEEvents",
"buildInteractionsContentWithToolCallsSSEEvents",
],
typesFile: null,
},
};

const SDK_SHAPES_FILE = "src/__tests__/drift/sdk-shapes.ts";
Expand Down
49 changes: 48 additions & 1 deletion src/__tests__/drift/gemini-interactions.drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import {
geminiInteractionsStreamEventShapes,
geminiInteractionsToolCallStreamEventShapes,
} from "./sdk-shapes.js";
import { geminiInteractionsNonStreaming, geminiInteractionsStreaming } from "./providers.js";
import {
geminiInteractionsNonStreaming,
geminiInteractionsNonStreamingSteps,
geminiInteractionsStreaming,
} from "./providers.js";
import { httpPost, parseInteractionsSSE, startDriftServer, stopDriftServer } from "./helpers.js";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -81,6 +85,49 @@ describe.skipIf(!GOOGLE_API_KEY)("Gemini Interactions API drift", () => {
).toEqual([]);
});

it("non-streaming text shape matches (Step[] input)", async () => {
const sdkShape = geminiInteractionsResponseShape();

let realRes;
try {
realRes = await geminiInteractionsNonStreamingSteps(config, "Say hello");
} catch (err) {
console.warn(
"Gemini Interactions API unavailable:",
err instanceof Error ? err.message : String(err),
);
return;
}

if (
!realRes.body ||
(typeof realRes.body === "object" && Object.keys(realRes.body).length === 0)
) {
console.warn("Gemini Interactions non-streaming API returned empty body — skipping");
return;
}

const mockRes = await httpPost(`${instance.url}/v1beta/interactions`, {
model: "gemini-2.5-flash",
input: [{ type: "user_input", content: [{ type: "text", text: "Say hello" }] }],
stream: false,
});

const realShape = extractShape(realRes.body);
const mockShape = extractShape(JSON.parse(mockRes.body));

const diffs = triangulate(sdkShape, realShape, mockShape);
const report = formatDriftReport(
"Gemini Interactions (non-streaming text, Step[] input)",
diffs,
);

expect(
diffs.filter((d) => d.severity === "critical"),
report,
).toEqual([]);
});

it("streaming text event sequence and shapes match", async () => {
const sdkEvents = geminiInteractionsStreamEventShapes();

Expand Down
74 changes: 74 additions & 0 deletions src/__tests__/drift/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,80 @@ export async function geminiInteractionsStreaming(
};
}

export async function geminiInteractionsNonStreamingSteps(
config: ProviderConfig,
input: string,
tools?: object[],
): Promise<FetchResult> {
const body: Record<string, unknown> = {
model: "gemini-2.5-flash",
input: [{ type: "user_input", content: [{ type: "text", text: input }] }],
stream: false,
};
if (tools) body.tools = tools;

const res = await fetchWithRetry(
`https://generativelanguage.googleapis.com/v1beta/interactions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": config.apiKey,
},
body: JSON.stringify(body),
},
);

const raw = await res.text();
return {
status: res.status,
body: parseJsonResponse(raw, res.status, "Gemini Interactions"),
raw,
};
}

export async function geminiInteractionsStreamingSteps(
config: ProviderConfig,
input: string,
tools?: object[],
): Promise<StreamResult> {
const body: Record<string, unknown> = {
model: "gemini-2.5-flash",
input: [{ type: "user_input", content: [{ type: "text", text: input }] }],
stream: true,
};
if (tools) body.tools = tools;

const res = await fetchWithRetry(
`https://generativelanguage.googleapis.com/v1beta/interactions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": config.apiKey,
},
body: JSON.stringify(body),
},
);

const raw = await res.text();
assertOk(raw, res.status, "Gemini Interactions streaming");
// Interactions uses data-only SSE (data: {...}\n\n) with event_type inside the JSON
const parsed = parseDataOnlySSE(raw);
const rawEvents = parsed.map((p) => {
const data = p.data as Record<string, unknown>;
return {
type: (data.event_type as string) ?? "unknown",
data: data,
};
});
return {
status: res.status,
events: toSSEEventShapes(rawEvents),
rawEvents,
};
}

// ---------------------------------------------------------------------------
// OpenAI Embeddings
// ---------------------------------------------------------------------------
Expand Down
203 changes: 203 additions & 0 deletions src/__tests__/gemini-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,193 @@ describe("geminiInteractionsToCompletionRequest", () => {
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toBe("from-content");
});

// ─── Step[] envelope (live API wire contract) ─────────────────────────

it("converts Step[] user_input step to a user message", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [{ type: "user_input", content: [{ type: "text", text: "hi" }] }],
});
expect(result.messages).toEqual([{ role: "user", content: "hi" }]);
});

it("converts Step[] user_input with multiple text parts (concatenates)", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [
{
type: "user_input",
content: [
{ type: "text", text: "part one " },
{ type: "text", text: "part two" },
],
},
],
});
expect(result.messages).toEqual([{ role: "user", content: "part one part two" }]);
});

it("converts Step[] function_result step to a tool message", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [
{
type: "function_result",
call_id: "call_abc",
name: "get_weather",
result: { temperature: 72 },
},
],
});
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe("tool");
expect(result.messages[0].content).toBe('{"temperature":72}');
expect(result.messages[0].tool_call_id).toBe("call_abc");
});

it("passes through Step[] function_result with string result", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [{ type: "function_result", call_id: "call_x", result: "ok" }],
});
expect(result.messages).toEqual([{ role: "tool", content: "ok", tool_call_id: "call_x" }]);
});

it("converts Step[] model_output text step to an assistant message", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [
{ type: "user_input", content: [{ type: "text", text: "hi" }] },
{ type: "model_output", content: [{ type: "text", text: "hello" }] },
],
});
expect(result.messages).toEqual([
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello" },
]);
});

it("converts Step[] model_output with function_call into assistant tool_calls", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [
{
type: "model_output",
content: [
{ type: "text", text: "Calling tool..." },
{
type: "function_call",
name: "search",
id: "call_x",
arguments: { query: "test" },
},
],
},
],
});
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe("assistant");
expect(result.messages[0].content).toBe("Calling tool...");
expect(result.messages[0].tool_calls).toHaveLength(1);
expect(result.messages[0].tool_calls![0].function.name).toBe("search");
expect(result.messages[0].tool_calls![0].id).toBe("call_x");
});

it("converts a multi-step Step[] agent loop in order", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [
{ type: "user_input", content: [{ type: "text", text: "what's the weather?" }] },
{
type: "model_output",
content: [
{
type: "function_call",
name: "get_weather",
id: "call_w",
arguments: { city: "NYC" },
},
],
},
{ type: "function_result", call_id: "call_w", result: { temp: 72 } },
{ type: "user_input", content: [{ type: "text", text: "thanks" }] },
],
});
expect(result.messages).toHaveLength(4);
expect(result.messages[0]).toEqual({ role: "user", content: "what's the weather?" });
expect(result.messages[1].role).toBe("assistant");
expect(result.messages[1].tool_calls![0].function.name).toBe("get_weather");
expect(result.messages[2]).toEqual({
role: "tool",
content: '{"temp":72}',
tool_call_id: "call_w",
});
expect(result.messages[3]).toEqual({ role: "user", content: "thanks" });
});

it("handles other *_result Step types as tool messages", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [
{
type: "code_execution_result",
call_id: "call_code",
result: "stdout: 42\n",
},
],
});
expect(result.messages).toEqual([
{ role: "tool", content: "stdout: 42\n", tool_call_id: "call_code" },
]);
});

it.each([
"url_context_result",
"google_search_result",
"google_maps_result",
"mcp_server_tool_result",
"file_search_result",
])("handles Step[] %s as a tool message", (stepType) => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [
{
type: stepType,
call_id: `call_${stepType}`,
result: { data: "test" },
},
],
});
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe("tool");
expect(result.messages[0].content).toBe('{"data":"test"}');
expect(result.messages[0].tool_call_id).toBe(`call_${stepType}`);
});

it("handles Step[] user_input with empty content", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [{ type: "user_input", content: [] }],
});
expect(result.messages).toEqual([{ role: "user", content: "" }]);
});

it("handles Step[] model_output with undefined content", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [{ type: "model_output" }],
});
expect(result.messages).toEqual([{ role: "assistant", content: "" }]);
});

it("handles Step[] model_output with empty content array", () => {
const result = geminiInteractionsToCompletionRequest({
model: "gemini-2.5-flash",
input: [{ type: "model_output", content: [] }],
});
expect(result.messages).toEqual([{ role: "assistant", content: "" }]);
});
});

// ─── Unit tests: response builders ──────────────────────────────────────
Expand Down Expand Up @@ -809,6 +996,22 @@ describe("Gemini Interactions — non-streaming", () => {
expect(body.error.code).toBe("UNAVAILABLE");
});

it("matches userMessage fixture when input is Step[] envelope (issue #228)", async () => {
// Reproduces the live wire contract that Google's /v1beta/interactions accepts:
// a top-level Array<Step> where each step is { type: "user_input", content: [...] }.
// Pre-fix this fell through to the Content[] branch and produced an empty user
// message, so userMessage-based fixtures never matched.
instance = await createServer([textFixture]);
const res = await post(`${instance.url}/v1beta/interactions`, {
model: "gemini-2.5-flash",
stream: false,
input: [{ type: "user_input", content: [{ type: "text", text: "hello" }] }],
});
expect(res.status).toBe(200);
const body = JSON.parse(res.body);
expect(body.outputs).toEqual([{ type: "text", text: "Hi there!" }]);
});

it("handles sequenceIndex for multi-turn", async () => {
instance = await createServer([...allFixtures]);
const r1 = await post(`${instance.url}/v1beta/interactions`, {
Expand Down
Loading
Loading