Transform Gemini Non-Streaming Output to AI SDK Wire Protocol Format
code:convert mastra format
/**
* Transform Gemini Non-Streaming Output to AI SDK Wire Protocol Format
*
* This module handles the conversion of Mastra's agent.generate() output into the SSE
* wire protocol format expected by the AI SDK's useChat hook.
*
* ## Background
*
* Gemini has issues with multi-turn conversations in streaming mode, so we use
* agent.generate() (synchronous) instead of agent.stream(). The complete response
* then needs to be converted into SSE format that the frontend's useChat hook can process.
*
* ## Critical: Wire Format for Tool Parts
*
* The AI SDK useChat hook expects tool parts in the **wire protocol format**:
*
* ### AI SDK Wire Protocol Format (CORRECT for SSE)
* `
* { type: 'tool-input-available', toolCallId: 'tc1', toolName: 'createChartTool', input: {...} }
* { type: 'tool-output-available', toolCallId: 'tc1', output: { chartConfig: {...} } }
* `
*
* The useChat hook then transforms these wire protocol events into the final UI message format
* that CopilotMessagePanel expects:
*
* ### UI Message Format (what useChat transforms wire protocol into)
* `
* {
* type: 'tool-createChartTool', // Transformed from toolName
* toolCallId: 'tc1',
* toolName: 'createChartTool',
* state: 'output-available', // Set based on receiving tool-output-available
* output: { valid: true, chartConfig: {...} }
* }
* `
*
* ## Why This Matters
*
* When streaming works (OpenAI via Mastra's toAISdkStream), the SSE contains wire protocol
* events which useChat transforms. For non-streaming (Gemini), we must produce the SAME
* wire protocol format so useChat applies the same transformation.
*
* @see {@link ../../../components/copilot/CopilotMessagePanel.tsx} for rendering logic
* @see {@link ../../../stories/copilot/CopilotMessagePanel.stories.tsx} for expected format examples
* @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol for AI SDK wire protocol
*/
interface UiMessageLike {
parts?: Array<{ type: string; text?: string; key: string: unknown }>;
}
interface StepLike {
text?: string;
response?: {
uiMessages?: UiMessageLike[];
};
}
export interface FullOutputLike {
response?: {
uiMessages?: UiMessageLike[];
};
text?: string;
steps?: StepLike[];
usage?: {
inputTokens?: number;
outputTokens?: number;
};
finishReason?: string;
}
export interface StreamPart {
type: string;
text?: string;
textDelta?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
output?: unknown;
key: string: unknown;
}
// Part types that should be passed through as-is (already in correct format)
const PASSTHROUGH_TYPES = new Set('text-delta', 'source', 'file', 'reasoning');
/**
* Transforms a single Mastra UI message part to AI SDK wire protocol format.
*
* ## Input (from Mastra's agent.generate() response)
*
* Text parts:
* `
* { type: 'text', text: 'Analysis complete...' }
* `
*
* Tool parts (note: type includes tool name):
* `
* {
* type: 'tool-createChartTool',
* toolCallId: 'tc_abc123',
* toolName: 'createChartTool', // May be missing
* result: { valid: true, chartConfig: {...} }
* }
* `
*
* ## Output (AI SDK wire protocol format for SSE → useChat transforms to UI Message)
*
* Text parts → text-delta format:
* `
* { type: 'text-delta', textDelta: 'Analysis complete...' }
* `
*
* Tool parts → TWO wire protocol events:
* `
* { type: 'tool-input-available', toolCallId: 'tc_abc123', toolName: 'createChartTool', input: {} }
* { type: 'tool-output-available', toolCallId: 'tc_abc123', output: { valid: true, chartConfig: {...} } }
* `
*
* The useChat hook transforms these into the UI message format that CopilotMessagePanel expects:
* `
* { type: 'tool-createChartTool', toolCallId, toolName, state: 'output-available', output: {...} }
* `
*
* @param part - A single part from Mastra's uiMessages[].parts array
* @returns Array of transformed parts (usually 1-2, but may be 0 if filtered)
*/
function transformPartToStreamFormat(part: Record<string, unknown>): StreamPart[] {
const type = part.type as string;
// TEXT PARTS
// Convert: { type: 'text', text: '...' } → { type: 'text-delta', textDelta: '...' }
// This is standard AI SDK format for streaming text
if (type === 'text') {
const text = (part.text as string) || '';
if (text.length === 0) return [];
return type: 'text-delta', textDelta: text };
}
// TOOL PARTS
// Mastra uses 'tool-{toolName}' format (e.g., 'tool-createChartTool')
//
// Convert to AI SDK wire protocol format:
// 1. tool-input-available - signals tool call with its input
// 2. tool-output-available - signals tool result
//
// The useChat hook transforms these into UI message format:
// { type: 'tool-{toolName}', state: 'output-available', output: {...} }
//
// See CopilotMessagePanel.tsx for how it renders the transformed parts:
// const CHART_TOOL_TYPES = new Set('tool-createChartTool', 'tool-create-chart-tool');
// const isValidChartPart = (p) => CHART_TOOL_TYPES.has(p.type) && p.state === 'output-available' && p.output?.valid
if (type.startsWith('tool-')) {
const toolCallId = (part.toolCallId as string) || tc_${Date.now()};
// Extract toolName from explicit property or derive from type
const toolName = (part.toolName as string) || type.replace('tool-', '');
// Mastra may put result in various properties
const result = part.result ?? part.output ?? part.payload ?? part.data ?? part.value;
// Input/args may be in various properties
const input = part.input ?? part.args ?? part.arguments ?? {};
// Return TWO events in wire protocol format:
// 1. tool-input-available - the tool was called with these inputs
// 2. tool-output-available - the tool returned this output
return [
{
type: 'tool-input-available',
toolCallId,
toolName,
input,
},
{
type: 'tool-output-available',
toolCallId,
output: result,
},
];
}
// MASTRA INTERNAL TYPES
// Skip step-start, step-finish, etc. - these are internal lifecycle events
if (type.startsWith('step-')) {
return [];
}
// PASSTHROUGH TYPES
// These are already in correct format (e.g., 'source', 'file', 'reasoning')
if (PASSTHROUGH_TYPES.has(type)) {
return part as StreamPart;
}
// UNKNOWN TYPES
// Skip anything we don't recognize to avoid breaking the stream
return [];
}
/**
* Extracts and transforms parts from a FullOutput result into AI SDK stream format.
*
* For non-streaming agent.generate(), Mastra may put content in different places:
* 1. result.response.uiMessages[].parts - primary location
* 2. result.steps[].response.uiMessages[].parts - step-level uiMessages
* 3. result.text - aggregated text from all steps
* 4. result.steps[].text - per-step text content
*
* @param result - The FullOutput from agent.generate()
* @returns Array of parts in AI SDK stream format that can be written to createUIMessageStream
*/
export function extractPartsFromFullOutput(result: unknown): StreamPart[] {
const typedResult = result as FullOutputLike;
// Helper to transform array of Mastra parts to AI SDK stream parts
const transformParts = (parts: Array<{ type: string; key: string: unknown }>): StreamPart[] =>
parts.flatMap((part) => transformPartToStreamFormat(part as Record<string, unknown>));
// 1. Check result.response.uiMessages (primary location)
const uiMessage = typedResult?.response?.uiMessages?.at(-1);
const parts = uiMessage?.parts || [];
if (parts.length > 0) {
const transformed = transformParts(parts);
if (transformed.length > 0) return transformed;
}
// 2. Check steps[].response.uiMessages for content
// IMPORTANT: Iterate backwards to get the LATEST step's content
// In multi-turn Gemini conversations, the steps array contains steps from ALL turns.
// We want the most recent step's content, not the first step's content.
const steps = typedResult?.steps || [];
for (let i = steps.length - 1; i >= 0; i -= 1) {
const step = stepsi;
const stepUiMessage = step?.response?.uiMessages?.at(-1);
const stepParts = stepUiMessage?.parts || [];
if (stepParts.length > 0) {
const transformed = transformParts(stepParts);
if (transformed.length > 0) return transformed;
}
}
// 3. Check result.text (aggregated text)
if (typedResult?.text && typedResult.text.length > 0) {
return type: 'text-delta', textDelta: typedResult.text };
}
// 4. Check steps[].text for any content (iterate backwards for latest)
for (let i = steps.length - 1; i >= 0; i -= 1) {
const step = stepsi;
if (step?.text && step.text.length > 0) {
return type: 'text-delta', textDelta: step.text };
}
}
// Final fallback
return type: 'text-delta', textDelta: 'Response generated successfully.' };
}