Skip to content
56 changes: 52 additions & 4 deletions packages/ai/src/protocols/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const GeminiInlineDataPart = Schema.Struct({
mimeType: Schema.String,
data: Schema.String,
}),
thought: Schema.optional(Schema.Boolean),
thoughtSignature: Schema.optional(Schema.String),
})
type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>

Expand Down Expand Up @@ -302,10 +304,25 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
// Parallel Gemini 3 calls may carry one signature on the first call; unsigned sibling calls are valid.
let hasSignedToolCall = false
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
if (!ProviderShared.supportsContent(part, ["text", "media", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", [
"text",
"media",
"reasoning",
"tool-call",
])
if (part.type === "text") {
parts.push({ text: part.text })
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
continue
}
if (part.type === "media") {
const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES)
parts.push({
inlineData: { mimeType: media.mime, data: media.base64 },
thoughtSignature:
thoughtSignature(part.providerMetadata) ??
(media.mime.startsWith("image/") ? SKIP_THOUGHT_SIGNATURE_VALIDATOR : undefined),
})
continue
}
if (part.type === "reasoning") {
Expand Down Expand Up @@ -548,8 +565,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
for (const part of candidate.content.parts) {
if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought)
reasoningSignature = part.thoughtSignature
if ("text" in part && part.text.length > 0) {
if ("text" in part) {
if (part.thought) {
if (part.text.length === 0) continue
lifecycle = Lifecycle.reasoningDelta(
lifecycle,
events,
Expand All @@ -565,10 +583,40 @@ const step = (state: ParserState, event: GeminiEvent) => {
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
const metadata = part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined
if (part.text.length === 0) {
if (metadata) {
lifecycle = Lifecycle.textStart(lifecycle, events, "text-0", metadata)
lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", metadata)
}
continue
}
lifecycle = Lifecycle.textStart(lifecycle, events, "text-0", metadata)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
continue
}

if ("inlineData" in part) {
if (part.thought) continue
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
)
lifecycle = Lifecycle.stepStart(lifecycle, events)
events.push(
LLMEvent.file({
mediaType: part.inlineData.mimeType,
data: part.inlineData.data,
providerMetadata: part.thoughtSignature
? googleMetadata({ thoughtSignature: part.thoughtSignature })
: undefined,
}),
)
continue
}

if ("functionCall" in part) {
const input = part.functionCall.args
const id = `tool_${nextToolCallId++}`
Expand Down
18 changes: 18 additions & 0 deletions packages/ai/src/schema/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ export const ReasoningEnd = Schema.Struct({
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>

export const File = Schema.Struct({
type: Schema.tag("file"),
mediaType: Schema.String,
data: Schema.Union([Schema.String, Schema.Uint8Array]),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.File" })
export type File = Schema.Schema.Type<typeof File>

export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
Expand Down Expand Up @@ -229,6 +237,7 @@ const llmEventTagged = Schema.Union([
ReasoningStart,
ReasoningDelta,
ReasoningEnd,
File,
ToolInputStart,
ToolInputDelta,
ToolInputEnd,
Expand Down Expand Up @@ -265,6 +274,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }),
reasoningEnd: (input: WithID<ReasoningEnd, ContentBlockID>) =>
ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }),
file: File.make,
toolInputStart: (input: WithID<ToolInputStart, ToolCallID>) =>
ToolInputStart.make({ ...input, id: toolCallID(input.id) }),
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
Expand Down Expand Up @@ -299,6 +309,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
reasoningStart: llmEventTagged.guards["reasoning-start"],
reasoningDelta: llmEventTagged.guards["reasoning-delta"],
reasoningEnd: llmEventTagged.guards["reasoning-end"],
file: llmEventTagged.guards.file,
toolInputStart: llmEventTagged.guards["tool-input-start"],
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
toolInputEnd: llmEventTagged.guards["tool-input-end"],
Expand Down Expand Up @@ -527,6 +538,13 @@ const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState =>
const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => {
const next = appendEvent(state, event)
switch (event.type) {
case "file":
return appendContent(next, {
type: "media",
mediaType: event.mediaType,
data: event.data,
providerMetadata: event.providerMetadata,
})
case "text-start":
return ensureText(next, event.id, event.providerMetadata)
case "text-delta":
Expand Down
1 change: 1 addition & 0 deletions packages/ai/src/schema/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const MediaPart = Schema.Struct({
data: Schema.Union([Schema.String, Schema.Uint8Array]),
filename: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>

Expand Down
140 changes: 134 additions & 6 deletions packages/ai/test/provider/gemini.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,36 @@ describe("Gemini route", () => {
}),
)

it.effect("preserves thoughtSignature from a final empty text chunk", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ candidates: [{ content: { role: "model", parts: [{ text: "hello" }] } }] },
{
candidates: [
{
content: { role: "model", parts: [{ text: "", thoughtSignature: "text_sig" }] },
finishReason: "STOP",
},
],
},
),
),
),
)

expect(response.message.content).toEqual([
{
type: "text",
text: "hello",
providerMetadata: { google: { thoughtSignature: "text_sig" } },
},
])
}),
)

it.effect("emits streamed tool calls and maps finish reason", () =>
Effect.gen(function* () {
const body = sseEvents({
Expand Down Expand Up @@ -801,6 +831,55 @@ describe("Gemini route", () => {
}),
)

it.effect("emits generated inline images as file events", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [
{ text: "[IMAGE]", thoughtSignature: "final_text" },
{
inlineData: { mimeType: "image/png", data: "ignored" },
thought: true,
thoughtSignature: "thought_image",
},
{
inlineData: { mimeType: "image/png", data: "AAECAw==" },
thoughtSignature: "final_image",
},
],
},
finishReason: "STOP",
},
],
})
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))

expect(response.events.find((event) => event.type === "file")).toEqual({
type: "file",
mediaType: "image/png",
data: "AAECAw==",
providerMetadata: { google: { thoughtSignature: "final_image" } },
})
expect(response.events.filter((event) => event.type === "file")).toHaveLength(1)
expect(response.message.content).toEqual([
{
type: "text",
text: "[IMAGE]",
providerMetadata: { google: { thoughtSignature: "final_text" } },
},
{
type: "media",
mediaType: "image/png",
data: "AAECAw==",
providerMetadata: { google: { thoughtSignature: "final_image" } },
},
])
}),
)

it.effect("assigns unique ids to multiple streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents({
Expand Down Expand Up @@ -918,19 +997,68 @@ describe("Gemini route", () => {
}),
)

it.effect("rejects unsupported assistant media content", () =>
it.effect("replays generated assistant images for conversational editing", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const prepared = yield* compileRequest(
LLM.request({
id: "req_media",
model,
messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
messages: [
Message.assistant([
{
type: "text",
text: "Here is the dinosaur.",
providerMetadata: { google: { thoughtSignature: "text-signature" } },
},
{
type: "media",
mediaType: "image/png",
data: "data:image/png;base64,AAECAw==",
providerMetadata: { google: { thoughtSignature: "image-signature" } },
},
]),
Message.user("Make it a T-Rex."),
],
}),
).pipe(Effect.flip)
)

expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ text: "Here is the dinosaur.", thoughtSignature: "text-signature" },
{
inlineData: { mimeType: "image/png", data: "AAECAw==" },
thoughtSignature: "image-signature",
},
],
},
{ role: "user", parts: [{ text: "Make it a T-Rex." }] },
])
}),
)

expect(error.message).toContain(
"Gemini assistant messages only support text, reasoning, and tool-call content for now",
it.effect("replays legacy generated images with Gemini's signature validator bypass", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
id: "req_legacy_media",
model,
messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
}),
)

expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{
inlineData: { mimeType: "image/png", data: "AAECAw==" },
thoughtSignature: "skip_thought_signature_validator",
},
],
},
])
}),
)
})
8 changes: 8 additions & 0 deletions packages/ai/test/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ describe("llm schema", () => {
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
})

test("decodes generated file events", () => {
expect(decodeLLMEvent({ type: "file", mediaType: "image/png", data: "iVBORw0KGgo=" })).toMatchObject({
type: "file",
mediaType: "image/png",
data: "iVBORw0KGgo=",
})
})

test("content part tagged union exposes guards", () => {
expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true)
expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false)
Expand Down
49 changes: 49 additions & 0 deletions packages/app/src/context/server-session-v2-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,55 @@ describe("v2 session reducer", () => {
})
})

test("projects model-generated files into live assistant content", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
const apply = (input: object) => {
const result = reducer.reduce(messages, event(input))
if (result) messages = result.messages
return result
}

apply({
...base,
id: "evt_step",
type: "session.step.started",
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
agent: "build",
model: { id: "image-model", providerID: "google" },
},
})
const generated = {
...base,
id: "evt_file",
type: "session.file.generated",
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
file: {
type: "file",
id: "generated-msg_assistant-0",
mime: "image/jpeg",
filename: "generated-msg_assistant-0.jpg",
url: "data:image/jpeg;base64,/9j/",
},
},
}
const result = apply(generated)
apply(generated)

expect(result?.touched).toEqual(["msg_assistant"])
expect(messages).toEqual([
expect.objectContaining({
id: "msg_assistant",
type: "assistant",
content: [generated.data.file],
}),
])
})

test("requests hydration when promotion admission was missed", () => {
const result = createV2SessionReducer().reduce(
[],
Expand Down
Loading
Loading