Skip to content

gemini-interactions: input parser doesn't recognize top-level Step[] envelope (drops user_input / function_result, breaks fixture matching) #228

Description

@tombeckenham

Summary

handleGeminiInteractions in src/gemini-interactions.ts doesn't recognize the Step[] envelope shape that the live Gemini Interactions API requires at the top level of input. Clients sending the format Google's /v1beta/interactions actually accepts get an empty userMessage and 404 "No fixture matched".

What aimock currently handles

geminiInteractionsToCompletionRequest (dist/gemini-interactions.cjs:9) covers three input shapes:

if (typeof req.input === "string") {  }                       // ✓
else if (Array.isArray(req.input)) {
  const firstItem = req.input[0];
  if (firstItem && "role" in firstItem) {  Turn[]  }         // ✓ (merged via #139)
  else {                                                       // ✗ falls here for Step[]
    const text = req.input
      .filter(p => p.type === "text")
      .map(p => p.text ?? "")
      .join("");
    messages.push({ role: "user", content: text || "" });
  }
}

Step[] items look like { type: "user_input", content: [...] } — no role key, so they hit the else branch; type !== "text" at the top level, so text is "". The synthetic message becomes { role: "user", content: "" }, fixture matching by userMessage fails, request returns 404.

Why the live API needs Step[]

Per Google's Interactions API docs, input accepts a list of Steps where each step is one of:

{ "type": "user_input",      "content": [ { "type": "text", "text": "" } ] }
{ "type": "function_result", "call_id": "", "name": "", "result": "" }
// also: tool_call, model_output, etc. — see the Step union.

Sending raw Array<Content> (the shape aimock's else branch implicitly assumes) produces a live-API invalid_request with "value at top-level must be a list". Sending Array<Turn> works on some SDK paths but not all.

The @google/genai SDK's TypeScript type union (string | Array<Content> | Array<Turn> | TextContent | ...) does not include Array<Step> — the type is incomplete relative to what the live API requires. This means every client that follows the live wire contract sends Step[] and is invisible to aimock.

Repro

import { LLMock } from '@copilotkit/aimock'

const mock = new LLMock({ port: 4010, host: '127.0.0.1' })
// Equivalent of a JSON fixture: { match: { userMessage: 'hi' }, response: { content: 'hello' } }
await mock.loadFixture({ match: { userMessage: 'hi' }, response: { content: 'hello' } })
await mock.start()

await fetch('http://127.0.0.1:4010/v1beta/interactions', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    model: 'gemini-2.5-flash',
    stream: true,
    input: [
      { type: 'user_input', content: [{ type: 'text', text: 'hi' }] },
    ],
  }),
})
// → 404 { error: { code: "NOT_FOUND", message: "No fixture matched" } }

Swap input for 'hi' and it matches as expected.

Suggested fix

Add a Step[] branch in geminiInteractionsToCompletionRequest. Sketch:

else if (firstItem && typeof firstItem.type === 'string' &&
         (firstItem.type === 'user_input' || firstItem.type === 'function_result' /* … */)) {
  for (const step of req.input) {
    if (step.type === 'user_input') {
      const text = (step.content ?? [])
        .filter(p => p.type === 'text')
        .map(p => p.text ?? '').join('')
      messages.push({ role: 'user', content: text })
    } else if (step.type === 'function_result') {
      messages.push({
        role: 'tool',
        content: typeof step.result === 'string' ? step.result : JSON.stringify(step.result ?? ''),
        tool_call_id: step.call_id ?? step.id ?? '',
      })
    }
    // tool_call / model_output / etc. as needed
  }
}

The discriminator (no role + type === 'user_input' | 'function_result' | …) reliably separates Step[] from the existing ContentBlock[] fallback.

Impact

  • TanStack AI's @tanstack/ai-gemini/experimental geminiTextInteractions() adapter sends Step[] (because it's what the live API accepts). E2E tests that exercise it against aimock all 404.
  • Any other client built to the live /v1beta/interactions wire contract has the same problem.

Versions

  • @copilotkit/aimock@1.26.1 (confirmed identical input branch through 1.24.1 → 1.25.0 → 1.26.0 → 1.26.1)
  • @google/genai@1.43.0

Happy to send a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions