diff --git a/packages/core/src/agent/agent-session.test.ts b/packages/core/src/agent/agent-session.test.ts index c390d719d4..235b4eb013 100644 --- a/packages/core/src/agent/agent-session.test.ts +++ b/packages/core/src/agent/agent-session.test.ts @@ -32,9 +32,7 @@ describe('AgentSession', () => { await session.abort(); expect( session.events.some( - (e) => - e.type === 'agent_end' && - (e as AgentEvent<'agent_end'>).reason === 'aborted', + (e) => e.type === 'agent_end' && e.reason === 'aborted', ), ).toBe(true); }); diff --git a/packages/core/src/agent/event-translator.test.ts b/packages/core/src/agent/event-translator.test.ts new file mode 100644 index 0000000000..f40c6c27ad --- /dev/null +++ b/packages/core/src/agent/event-translator.test.ts @@ -0,0 +1,733 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, beforeEach } from 'vitest'; +import { FinishReason } from '@google/genai'; +import { ToolErrorType } from '../tools/tool-error.js'; +import { + translateEvent, + createTranslationState, + mapFinishReason, + mapHttpToGrpcStatus, + mapError, + mapUsage, + type TranslationState, +} from './event-translator.js'; +import { GeminiEventType } from '../core/turn.js'; +import type { ServerGeminiStreamEvent } from '../core/turn.js'; +import type { AgentEvent } from './types.js'; + +describe('createTranslationState', () => { + it('creates state with default streamId', () => { + const state = createTranslationState(); + expect(state.streamId).toBeDefined(); + expect(state.streamStartEmitted).toBe(false); + expect(state.model).toBeUndefined(); + expect(state.eventCounter).toBe(0); + expect(state.pendingToolNames.size).toBe(0); + }); + + it('creates state with custom streamId', () => { + const state = createTranslationState('custom-stream'); + expect(state.streamId).toBe('custom-stream'); + }); +}); + +describe('translateEvent', () => { + let state: TranslationState; + + beforeEach(() => { + state = createTranslationState('test-stream'); + }); + + describe('Content events', () => { + it('emits agent_start + message for first content event', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Content, + value: 'Hello world', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(2); + expect(result[0]?.type).toBe('agent_start'); + expect(result[1]?.type).toBe('message'); + const msg = result[1] as AgentEvent<'message'>; + expect(msg.role).toBe('agent'); + expect(msg.content).toEqual([{ type: 'text', text: 'Hello world' }]); + }); + + it('skips agent_start for subsequent content events', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Content, + value: 'more text', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('message'); + }); + }); + + describe('Thought events', () => { + it('emits thought content with metadata', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Thought, + value: { subject: 'Planning', description: 'I am thinking...' }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const msg = result[0] as AgentEvent<'message'>; + expect(msg.content).toEqual([ + { type: 'thought', thought: 'I am thinking...' }, + ]); + expect(msg._meta?.['subject']).toBe('Planning'); + }); + }); + + describe('ToolCallRequest events', () => { + it('emits tool_request and tracks pending tool name', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'call-1', + name: 'read_file', + args: { path: '/tmp/test' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const req = result[0] as AgentEvent<'tool_request'>; + expect(req.requestId).toBe('call-1'); + expect(req.name).toBe('read_file'); + expect(req.args).toEqual({ path: '/tmp/test' }); + expect(state.pendingToolNames.get('call-1')).toBe('read_file'); + }); + }); + + describe('ToolCallResponse events', () => { + it('emits tool_response with content from responseParts', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-1', 'read_file'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-1', + responseParts: [{ text: 'file contents' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.requestId).toBe('call-1'); + expect(resp.name).toBe('read_file'); + expect(resp.content).toEqual([{ type: 'text', text: 'file contents' }]); + expect(resp.isError).toBe(false); + expect(state.pendingToolNames.has('call-1')).toBe(false); + }); + + it('uses error.message for content when tool errored', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-2', 'write_file'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-2', + responseParts: [{ text: 'stale parts' }], + resultDisplay: 'Permission denied', + error: new Error('Permission denied to write'), + errorType: ToolErrorType.PERMISSION_DENIED, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.isError).toBe(true); + // Should use error.message, not responseParts + expect(resp.content).toEqual([ + { type: 'text', text: 'Permission denied to write' }, + ]); + expect(resp.displayContent).toEqual([ + { type: 'text', text: 'Permission denied' }, + ]); + expect(resp.data).toEqual({ errorType: 'permission_denied' }); + }); + + it('uses "unknown" name for untracked tool calls', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'untracked', + responseParts: [{ text: 'data' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.name).toBe('unknown'); + }); + + it('stringifies object resultDisplay correctly', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-3', 'diff_tool'); + const objectDisplay = { + fileDiff: '@@ -1 +1 @@\n-a\n+b', + fileName: 'test.txt', + filePath: '/tmp/test.txt', + originalContent: 'a', + newContent: 'b', + }; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-3', + responseParts: [{ text: 'diff result' }], + resultDisplay: objectDisplay, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.displayContent).toEqual([ + { type: 'text', text: JSON.stringify(objectDisplay) }, + ]); + }); + + it('passes through string resultDisplay as-is', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-4', 'shell'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-4', + responseParts: [{ text: 'output' }], + resultDisplay: 'Command output text', + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.displayContent).toEqual([ + { type: 'text', text: 'Command output text' }, + ]); + }); + + it('preserves outputFile and contentLength in data', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-5', 'write_file'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-5', + responseParts: [{ text: 'written' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + outputFile: '/tmp/out.txt', + contentLength: 42, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.data?.['outputFile']).toBe('/tmp/out.txt'); + expect(resp.data?.['contentLength']).toBe(42); + }); + + it('handles multi-part responses (text + inlineData)', () => { + state.streamStartEmitted = true; + state.pendingToolNames.set('call-6', 'screenshot'); + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallResponse, + value: { + callId: 'call-6', + responseParts: [ + { text: 'Here is the screenshot' }, + { inlineData: { data: 'base64img', mimeType: 'image/png' } }, + ], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + }; + const result = translateEvent(event, state); + const resp = result[0] as AgentEvent<'tool_response'>; + expect(resp.content).toEqual([ + { type: 'text', text: 'Here is the screenshot' }, + { type: 'media', data: 'base64img', mimeType: 'image/png' }, + ]); + expect(resp.isError).toBe(false); + }); + }); + + describe('Error events', () => { + it('emits error event for structured errors', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Error, + value: { error: { message: 'Rate limited', status: 429 } }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('RESOURCE_EXHAUSTED'); + expect(err.message).toBe('Rate limited'); + expect(err.fatal).toBe(true); + }); + + it('emits error event for Error instances', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Error, + value: { error: new Error('Something broke') }, + }; + const result = translateEvent(event, state); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('INTERNAL'); + expect(err.message).toBe('Something broke'); + }); + }); + + describe('ModelInfo events', () => { + it('emits agent_start and session_update when no stream started yet', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ModelInfo, + value: 'gemini-2.5-pro', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(2); + expect(result[0]?.type).toBe('agent_start'); + expect(result[1]?.type).toBe('session_update'); + const sessionUpdate = result[1] as AgentEvent<'session_update'>; + expect(sessionUpdate.model).toBe('gemini-2.5-pro'); + expect(state.model).toBe('gemini-2.5-pro'); + expect(state.streamStartEmitted).toBe(true); + }); + + it('emits session_update when stream already started', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ModelInfo, + value: 'gemini-2.5-flash', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('session_update'); + }); + }); + + describe('AgentExecutionStopped events', () => { + it('emits agent_end with the final stop message in data.message', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionStopped, + value: { + reason: 'before_model', + systemMessage: 'Stopped by hook', + contextCleared: true, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const streamEnd = result[0] as AgentEvent<'agent_end'>; + expect(streamEnd.type).toBe('agent_end'); + expect(streamEnd.reason).toBe('completed'); + expect(streamEnd.data).toEqual({ message: 'Stopped by hook' }); + }); + + it('uses reason when systemMessage is not set', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'hook' }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const streamEnd = result[0] as AgentEvent<'agent_end'>; + expect(streamEnd.data).toEqual({ message: 'hook' }); + }); + }); + + describe('AgentExecutionBlocked events', () => { + it('emits non-fatal error event (non-terminal, stream continues)', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Policy violation' }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.type).toBe('error'); + expect(err.fatal).toBe(false); + expect(err._meta?.['code']).toBe('AGENT_EXECUTION_BLOCKED'); + expect(err.message).toBe('Agent execution blocked: Policy violation'); + }); + + it('uses systemMessage in the final error message when available', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.AgentExecutionBlocked, + value: { + reason: 'hook_blocked', + systemMessage: 'Blocked by policy hook', + contextCleared: true, + }, + }; + const result = translateEvent(event, state); + const err = result[0] as AgentEvent<'error'>; + expect(err.message).toBe( + 'Agent execution blocked: Blocked by policy hook', + ); + }); + }); + + describe('LoopDetected events', () => { + it('emits a non-fatal warning error event', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.LoopDetected, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('error'); + const loopWarning = result[0] as AgentEvent<'error'>; + expect(loopWarning.fatal).toBe(false); + expect(loopWarning.message).toBe('Loop detected, stopping execution'); + expect(loopWarning._meta?.['code']).toBe('LOOP_DETECTED'); + }); + }); + + describe('MaxSessionTurns events', () => { + it('emits agent_end with max_turns', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.MaxSessionTurns, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const streamEnd = result[0] as AgentEvent<'agent_end'>; + expect(streamEnd.type).toBe('agent_end'); + expect(streamEnd.reason).toBe('max_turns'); + expect(streamEnd.data).toEqual({ code: 'MAX_TURNS_EXCEEDED' }); + }); + }); + + describe('Finished events', () => { + it('emits usage for STOP', () => { + state.streamStartEmitted = true; + state.model = 'gemini-2.5-pro'; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Finished, + value: { + reason: FinishReason.STOP, + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + }, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + + const usage = result[0] as AgentEvent<'usage'>; + expect(usage.model).toBe('gemini-2.5-pro'); + expect(usage.inputTokens).toBe(100); + expect(usage.outputTokens).toBe(50); + expect(usage.cachedTokens).toBe(10); + }); + + it('emits nothing when no usage metadata is present', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: undefined }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(0); + }); + }); + + describe('Citation events', () => { + it('emits message with citation meta', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Citation, + value: 'Source: example.com', + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const msg = result[0] as AgentEvent<'message'>; + expect(msg.content).toEqual([ + { type: 'text', text: 'Source: example.com' }, + ]); + expect(msg._meta?.['citation']).toBe(true); + }); + }); + + describe('UserCancelled events', () => { + it('emits agent_end with reason aborted', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.UserCancelled, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const end = result[0] as AgentEvent<'agent_end'>; + expect(end.type).toBe('agent_end'); + expect(end.reason).toBe('aborted'); + }); + }); + + describe('ContextWindowWillOverflow events', () => { + it('emits fatal error', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ContextWindowWillOverflow, + value: { + estimatedRequestTokenCount: 150000, + remainingTokenCount: 10000, + }, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('RESOURCE_EXHAUSTED'); + expect(err.fatal).toBe(true); + expect(err.message).toContain('150000'); + expect(err.message).toContain('10000'); + }); + }); + + describe('InvalidStream events', () => { + it('emits fatal error', () => { + state.streamStartEmitted = true; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.InvalidStream, + }; + const result = translateEvent(event, state); + expect(result).toHaveLength(1); + const err = result[0] as AgentEvent<'error'>; + expect(err.status).toBe('INTERNAL'); + expect(err.message).toBe('Invalid stream received from model'); + expect(err.fatal).toBe(true); + }); + }); + + describe('Events with no output', () => { + it('returns empty for Retry', () => { + const result = translateEvent({ type: GeminiEventType.Retry }, state); + expect(result).toEqual([]); + }); + + it('returns empty for ChatCompressed with null', () => { + const result = translateEvent( + { type: GeminiEventType.ChatCompressed, value: null }, + state, + ); + expect(result).toEqual([]); + }); + + it('returns empty for ToolCallConfirmation', () => { + // ToolCallConfirmation is skipped in non-interactive mode (elicitations + // are deferred to the interactive runtime adaptation). + const event = { + type: GeminiEventType.ToolCallConfirmation, + value: { + request: { + callId: 'c1', + name: 'tool', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }, + details: { type: 'info', title: 'Confirm', prompt: 'Confirm?' }, + }, + } as ServerGeminiStreamEvent; + const result = translateEvent(event, state); + expect(result).toEqual([]); + }); + }); + + describe('Event IDs', () => { + it('generates sequential IDs', () => { + state.streamStartEmitted = true; + const e1 = translateEvent( + { type: GeminiEventType.Content, value: 'a' }, + state, + ); + const e2 = translateEvent( + { type: GeminiEventType.Content, value: 'b' }, + state, + ); + expect(e1[0]?.id).toBe('test-stream-0'); + expect(e2[0]?.id).toBe('test-stream-1'); + }); + + it('includes streamId in events', () => { + const events = translateEvent( + { type: GeminiEventType.Content, value: 'hi' }, + state, + ); + for (const e of events) { + expect(e.streamId).toBe('test-stream'); + } + }); + }); +}); + +describe('mapFinishReason', () => { + it('maps STOP to completed', () => { + expect(mapFinishReason(FinishReason.STOP)).toBe('completed'); + }); + + it('maps undefined to completed', () => { + expect(mapFinishReason(undefined)).toBe('completed'); + }); + + it('maps MAX_TOKENS to max_budget', () => { + expect(mapFinishReason(FinishReason.MAX_TOKENS)).toBe('max_budget'); + }); + + it('maps SAFETY to refusal', () => { + expect(mapFinishReason(FinishReason.SAFETY)).toBe('refusal'); + }); + + it('maps MALFORMED_FUNCTION_CALL to failed', () => { + expect(mapFinishReason(FinishReason.MALFORMED_FUNCTION_CALL)).toBe( + 'failed', + ); + }); + + it('maps RECITATION to refusal', () => { + expect(mapFinishReason(FinishReason.RECITATION)).toBe('refusal'); + }); + + it('maps LANGUAGE to refusal', () => { + expect(mapFinishReason(FinishReason.LANGUAGE)).toBe('refusal'); + }); + + it('maps BLOCKLIST to refusal', () => { + expect(mapFinishReason(FinishReason.BLOCKLIST)).toBe('refusal'); + }); + + it('maps OTHER to failed', () => { + expect(mapFinishReason(FinishReason.OTHER)).toBe('failed'); + }); + + it('maps PROHIBITED_CONTENT to refusal', () => { + expect(mapFinishReason(FinishReason.PROHIBITED_CONTENT)).toBe('refusal'); + }); + + it('maps IMAGE_SAFETY to refusal', () => { + expect(mapFinishReason(FinishReason.IMAGE_SAFETY)).toBe('refusal'); + }); + + it('maps IMAGE_PROHIBITED_CONTENT to refusal', () => { + expect(mapFinishReason(FinishReason.IMAGE_PROHIBITED_CONTENT)).toBe( + 'refusal', + ); + }); + + it('maps UNEXPECTED_TOOL_CALL to failed', () => { + expect(mapFinishReason(FinishReason.UNEXPECTED_TOOL_CALL)).toBe('failed'); + }); + + it('maps NO_IMAGE to failed', () => { + expect(mapFinishReason(FinishReason.NO_IMAGE)).toBe('failed'); + }); +}); + +describe('mapHttpToGrpcStatus', () => { + it('maps 400 to INVALID_ARGUMENT', () => { + expect(mapHttpToGrpcStatus(400)).toBe('INVALID_ARGUMENT'); + }); + + it('maps 401 to UNAUTHENTICATED', () => { + expect(mapHttpToGrpcStatus(401)).toBe('UNAUTHENTICATED'); + }); + + it('maps 429 to RESOURCE_EXHAUSTED', () => { + expect(mapHttpToGrpcStatus(429)).toBe('RESOURCE_EXHAUSTED'); + }); + + it('maps undefined to INTERNAL', () => { + expect(mapHttpToGrpcStatus(undefined)).toBe('INTERNAL'); + }); + + it('maps unknown codes to INTERNAL', () => { + expect(mapHttpToGrpcStatus(418)).toBe('INTERNAL'); + }); +}); + +describe('mapError', () => { + it('maps structured errors with status', () => { + const result = mapError({ message: 'Rate limit', status: 429 }); + expect(result.status).toBe('RESOURCE_EXHAUSTED'); + expect(result.message).toBe('Rate limit'); + expect(result.fatal).toBe(true); + expect(result._meta?.['rawError']).toEqual({ + message: 'Rate limit', + status: 429, + }); + }); + + it('maps Error instances', () => { + const result = mapError(new Error('Something failed')); + expect(result.status).toBe('INTERNAL'); + expect(result.message).toBe('Something failed'); + }); + + it('preserves error name in _meta', () => { + class CustomError extends Error { + constructor(msg: string) { + super(msg); + } + } + const result = mapError(new CustomError('test')); + expect(result._meta?.['errorName']).toBe('CustomError'); + }); + + it('maps non-Error values to string', () => { + const result = mapError('raw string error'); + expect(result.message).toBe('raw string error'); + expect(result.status).toBe('INTERNAL'); + }); +}); + +describe('mapUsage', () => { + it('maps all fields', () => { + const result = mapUsage( + { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 25, + }, + 'gemini-2.5-pro', + ); + expect(result).toEqual({ + model: 'gemini-2.5-pro', + inputTokens: 100, + outputTokens: 50, + cachedTokens: 25, + }); + }); + + it('uses "unknown" for missing model', () => { + const result = mapUsage({}); + expect(result.model).toBe('unknown'); + }); +}); diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts new file mode 100644 index 0000000000..73f93f4a15 --- /dev/null +++ b/packages/core/src/agent/event-translator.ts @@ -0,0 +1,457 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Pure, stateless-per-call translation functions that convert + * ServerGeminiStreamEvent objects into AgentEvent objects. + * + * No side effects, no generators. Each call to `translateEvent` takes an event + * and mutable TranslationState, returning zero or more AgentEvents. + */ + +import type { FinishReason } from '@google/genai'; +import { GeminiEventType } from '../core/turn.js'; +import type { + ServerGeminiStreamEvent, + StructuredError, + GeminiFinishedEventValue, +} from '../core/turn.js'; +import type { + AgentEvent, + StreamEndReason, + ErrorData, + Usage, + AgentEventType, +} from './types.js'; +import { + geminiPartsToContentParts, + toolResultDisplayToContentParts, + buildToolResponseData, +} from './content-utils.js'; + +// --------------------------------------------------------------------------- +// Translation State +// --------------------------------------------------------------------------- + +export interface TranslationState { + streamId: string; + streamStartEmitted: boolean; + model: string | undefined; + eventCounter: number; + /** Tracks callId → tool name from requests so responses can reference the name. */ + pendingToolNames: Map; +} + +export function createTranslationState(streamId?: string): TranslationState { + return { + streamId: streamId ?? crypto.randomUUID(), + streamStartEmitted: false, + model: undefined, + eventCounter: 0, + pendingToolNames: new Map(), + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeEvent( + type: T, + state: TranslationState, + payload: Partial>, +): AgentEvent { + const id = `${state.streamId}-${state.eventCounter++}`; + // TypeScript cannot preserve the specific discriminated union member across + // this generic object assembly, so keep the narrowing local to the event + // constructor boundary. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { + ...payload, + id, + timestamp: new Date().toISOString(), + streamId: state.streamId, + type, + } as AgentEvent; +} + +function ensureStreamStart(state: TranslationState, out: AgentEvent[]): void { + if (!state.streamStartEmitted) { + out.push(makeEvent('agent_start', state, {})); + state.streamStartEmitted = true; + } +} + +// --------------------------------------------------------------------------- +// Core Translator +// --------------------------------------------------------------------------- + +/** + * Translates a single ServerGeminiStreamEvent into zero or more AgentEvents. + * Mutates `state` (counter, flags) as a side effect. + */ +export function translateEvent( + event: ServerGeminiStreamEvent, + state: TranslationState, +): AgentEvent[] { + const out: AgentEvent[] = []; + + switch (event.type) { + case GeminiEventType.ModelInfo: + state.model = event.value; + ensureStreamStart(state, out); + out.push(makeEvent('session_update', state, { model: event.value })); + break; + + case GeminiEventType.Content: + ensureStreamStart(state, out); + out.push( + makeEvent('message', state, { + role: 'agent', + content: [{ type: 'text', text: event.value }], + }), + ); + break; + + case GeminiEventType.Thought: + ensureStreamStart(state, out); + out.push( + makeEvent('message', state, { + role: 'agent', + content: [{ type: 'thought', thought: event.value.description }], + _meta: event.value.subject + ? { source: 'agent', subject: event.value.subject } + : { source: 'agent' }, + }), + ); + break; + + case GeminiEventType.Citation: + ensureStreamStart(state, out); + out.push( + makeEvent('message', state, { + role: 'agent', + content: [{ type: 'text', text: event.value }], + _meta: { source: 'agent', citation: true }, + }), + ); + break; + + case GeminiEventType.Finished: + handleFinished(event.value, state, out); + break; + + case GeminiEventType.Error: + handleError(event.value.error, state, out); + break; + + case GeminiEventType.UserCancelled: + ensureStreamStart(state, out); + out.push( + makeEvent('agent_end', state, { + reason: 'aborted', + }), + ); + break; + + case GeminiEventType.MaxSessionTurns: + ensureStreamStart(state, out); + out.push( + makeEvent('agent_end', state, { + reason: 'max_turns', + data: { + code: 'MAX_TURNS_EXCEEDED', + }, + }), + ); + break; + + case GeminiEventType.LoopDetected: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'INTERNAL', + message: 'Loop detected, stopping execution', + fatal: false, + _meta: { code: 'LOOP_DETECTED' }, + }), + ); + break; + + case GeminiEventType.ContextWindowWillOverflow: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'RESOURCE_EXHAUSTED', + message: `Context window will overflow (estimated: ${event.value.estimatedRequestTokenCount}, remaining: ${event.value.remainingTokenCount})`, + fatal: true, + }), + ); + break; + + case GeminiEventType.AgentExecutionStopped: + ensureStreamStart(state, out); + out.push( + makeEvent('agent_end', state, { + reason: 'completed', + data: { + message: event.value.systemMessage?.trim() || event.value.reason, + }, + }), + ); + break; + + case GeminiEventType.AgentExecutionBlocked: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'PERMISSION_DENIED', + message: `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`, + fatal: false, + _meta: { code: 'AGENT_EXECUTION_BLOCKED' }, + }), + ); + break; + + case GeminiEventType.InvalidStream: + ensureStreamStart(state, out); + out.push( + makeEvent('error', state, { + status: 'INTERNAL', + message: 'Invalid stream received from model', + fatal: true, + }), + ); + break; + + case GeminiEventType.ToolCallRequest: + ensureStreamStart(state, out); + state.pendingToolNames.set(event.value.callId, event.value.name); + out.push( + makeEvent('tool_request', state, { + requestId: event.value.callId, + name: event.value.name, + args: event.value.args, + }), + ); + break; + + case GeminiEventType.ToolCallResponse: { + ensureStreamStart(state, out); + const displayContent = toolResultDisplayToContentParts( + event.value.resultDisplay, + ); + const data = buildToolResponseData(event.value); + out.push( + makeEvent('tool_response', state, { + requestId: event.value.callId, + name: state.pendingToolNames.get(event.value.callId) ?? 'unknown', + content: event.value.error + ? [{ type: 'text', text: event.value.error.message }] + : geminiPartsToContentParts(event.value.responseParts), + isError: event.value.error !== undefined, + ...(displayContent ? { displayContent } : {}), + ...(data ? { data } : {}), + }), + ); + state.pendingToolNames.delete(event.value.callId); + break; + } + + case GeminiEventType.ToolCallConfirmation: + // Elicitations are handled separately by the session layer + break; + + // Internal concerns — no AgentEvent emitted + case GeminiEventType.ChatCompressed: + case GeminiEventType.Retry: + break; + + default: + ((x: never) => { + throw new Error(`Unhandled event type: ${JSON.stringify(x)}`); + })(event); + break; + } + + return out; +} + +// --------------------------------------------------------------------------- +// Finished Event Handling +// --------------------------------------------------------------------------- + +function handleFinished( + value: GeminiFinishedEventValue, + state: TranslationState, + out: AgentEvent[], +): void { + if (value.usageMetadata) { + ensureStreamStart(state, out); + const usage = mapUsage(value.usageMetadata, state.model); + out.push(makeEvent('usage', state, usage)); + } +} + +// --------------------------------------------------------------------------- +// Error Handling +// --------------------------------------------------------------------------- + +function handleError( + error: unknown, + state: TranslationState, + out: AgentEvent[], +): void { + ensureStreamStart(state, out); + + const mapped = mapError(error); + out.push(makeEvent('error', state, mapped)); +} + +// --------------------------------------------------------------------------- +// Public Mapping Functions +// --------------------------------------------------------------------------- + +/** + * Maps a Gemini FinishReason to an AgentEnd reason. + */ +export function mapFinishReason( + reason: FinishReason | undefined, +): StreamEndReason { + if (!reason) return 'completed'; + + switch (reason) { + case 'STOP': + case 'FINISH_REASON_UNSPECIFIED': + return 'completed'; + case 'MAX_TOKENS': + return 'max_budget'; + case 'SAFETY': + case 'RECITATION': + case 'LANGUAGE': + case 'BLOCKLIST': + case 'PROHIBITED_CONTENT': + case 'SPII': + case 'IMAGE_SAFETY': + case 'IMAGE_PROHIBITED_CONTENT': + return 'refusal'; + case 'MALFORMED_FUNCTION_CALL': + case 'OTHER': + case 'UNEXPECTED_TOOL_CALL': + case 'NO_IMAGE': + return 'failed'; + default: + return 'failed'; + } +} + +/** + * Maps an HTTP status code to a gRPC-style status string. + */ +export function mapHttpToGrpcStatus( + httpStatus: number | undefined, +): ErrorData['status'] { + if (httpStatus === undefined) return 'INTERNAL'; + + switch (httpStatus) { + case 400: + return 'INVALID_ARGUMENT'; + case 401: + return 'UNAUTHENTICATED'; + case 403: + return 'PERMISSION_DENIED'; + case 404: + return 'NOT_FOUND'; + case 409: + return 'ALREADY_EXISTS'; + case 429: + return 'RESOURCE_EXHAUSTED'; + case 500: + return 'INTERNAL'; + case 501: + return 'UNIMPLEMENTED'; + case 503: + return 'UNAVAILABLE'; + case 504: + return 'DEADLINE_EXCEEDED'; + default: + return 'INTERNAL'; + } +} + +/** + * Maps a StructuredError (or unknown error value) to an ErrorData payload. + * Preserves selected error metadata in _meta and includes raw structured + * errors for lossless debugging. + */ +export function mapError( + error: unknown, +): ErrorData & { _meta?: Record } { + const meta: Record = {}; + + if (error instanceof Error) { + meta['errorName'] = error.constructor.name; + if ('exitCode' in error && typeof error.exitCode === 'number') { + meta['exitCode'] = error.exitCode; + } + if ('code' in error) { + meta['code'] = error.code; + } + } + + if (isStructuredError(error)) { + const structuredMeta = { ...meta, rawError: error }; + return { + status: mapHttpToGrpcStatus(error.status), + message: error.message, + fatal: true, + _meta: structuredMeta, + }; + } + + if (error instanceof Error) { + return { + status: 'INTERNAL', + message: error.message, + fatal: true, + ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), + }; + } + + return { + status: 'INTERNAL', + message: String(error), + fatal: true, + }; +} + +function isStructuredError(error: unknown): error is StructuredError { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ); +} + +/** + * Maps Gemini usageMetadata to Usage. + */ +export function mapUsage( + metadata: { + promptTokenCount?: number; + candidatesTokenCount?: number; + cachedContentTokenCount?: number; + }, + model?: string, +): Usage { + return { + model: model ?? 'unknown', + inputTokens: metadata.promptTokenCount, + outputTokens: metadata.candidatesTokenCount, + cachedTokens: metadata.cachedContentTokenCount, + }; +} diff --git a/packages/core/src/agent/mock.ts b/packages/core/src/agent/mock.ts index f29e87f878..683e3e0b2a 100644 --- a/packages/core/src/agent/mock.ts +++ b/packages/core/src/agent/mock.ts @@ -86,6 +86,7 @@ export class MockAgentProtocol implements AgentProtocol { ) { const now = new Date().toISOString(); for (const eventData of events) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const event: AgentEvent = { ...eventData, id: eventData.id ?? `e-${this._nextEventId++}`, @@ -126,6 +127,7 @@ export class MockAgentProtocol implements AgentProtocol { // Helper to normalize and prepare for emission const normalize = (eventData: MockAgentEvent): AgentEvent => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ({ ...eventData, id: eventData.id ?? `e-${this._nextEventId++}`, diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 3b1c740ad4..014998d68b 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -81,9 +81,18 @@ export type AgentEventData< EventType extends keyof AgentEvents = keyof AgentEvents, > = AgentEvents[EventType] & { type: EventType }; +/** + * Mapped type that produces a proper discriminated union when `EventType` is + * the default (all keys), enabling `switch (event.type)` narrowing. + * When a specific EventType is provided, resolves to a single variant. + */ export type AgentEvent< EventType extends keyof AgentEvents = keyof AgentEvents, -> = AgentEventCommon & AgentEventData; +> = { + [K in EventType]: AgentEventCommon & AgentEvents[K] & { type: K }; +}[EventType]; + +export type AgentEventType = keyof AgentEvents; export interface AgentEvents { /** MUST be the first event emitted in a session. */ @@ -263,7 +272,7 @@ export interface AgentStart { streamId: string; } -type StreamEndReason = +export type StreamEndReason = | 'completed' | 'failed' | 'aborted'