Skip to content

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

@tombeckenham

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions