mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14fe9815d0 | |||
| 2582669d62 | |||
| 7abec8e3c7 | |||
| f038ba417f | |||
| 1ee198cc2c | |||
| 18544cc994 | |||
| 139a5f3202 | |||
| db0624c6cd | |||
| 1947629a38 | |||
| 3878599772 | |||
| db35ecd705 | |||
| 1b6c609165 | |||
| 6c9f644417 | |||
| cc2058296c | |||
| fb1b814ff8 |
@@ -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);
|
||||
});
|
||||
@@ -119,6 +117,7 @@ describe('AgentSession', () => {
|
||||
expect(events).toHaveLength(0);
|
||||
expect(protocol.events).toHaveLength(1);
|
||||
expect(protocol.events[0].type).toBe('session_update');
|
||||
expect(protocol.events[0].streamId).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('should skip events that occur before agent_start', async () => {
|
||||
@@ -173,6 +172,181 @@ describe('AgentSession', () => {
|
||||
expect(streamedEvents).toEqual(allEvents.slice(2));
|
||||
});
|
||||
|
||||
it('should complete immediately when resuming from agent_end', async () => {
|
||||
const protocol = new MockAgentProtocol();
|
||||
const session = new AgentSession(protocol);
|
||||
|
||||
protocol.pushResponse([{ type: 'message' }]);
|
||||
const { streamId } = await session.send({
|
||||
message: [{ type: 'text', text: 'request' }],
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const endEvent = session.events.findLast(
|
||||
(event): event is AgentEvent<'agent_end'> =>
|
||||
event.type === 'agent_end' && event.streamId === streamId,
|
||||
);
|
||||
expect(endEvent).toBeDefined();
|
||||
|
||||
const iterator = session
|
||||
.stream({ eventId: endEvent!.id })
|
||||
[Symbol.asyncIterator]();
|
||||
await expect(iterator.next()).resolves.toEqual({
|
||||
value: undefined,
|
||||
done: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw for an unknown eventId', async () => {
|
||||
const protocol = new MockAgentProtocol();
|
||||
const session = new AgentSession(protocol);
|
||||
|
||||
const iterator = session
|
||||
.stream({ eventId: 'missing-event' })
|
||||
[Symbol.asyncIterator]();
|
||||
await expect(iterator.next()).rejects.toThrow(
|
||||
'Unknown eventId: missing-event',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when resuming from an event before agent_start on a stream with no agent activity', async () => {
|
||||
const protocol = new MockAgentProtocol();
|
||||
const session = new AgentSession(protocol);
|
||||
|
||||
const { streamId } = await session.send({ update: { title: 'draft' } });
|
||||
expect(streamId).toBeNull();
|
||||
|
||||
const updateEvent = session.events.find(
|
||||
(event): event is AgentEvent<'session_update'> =>
|
||||
event.type === 'session_update',
|
||||
);
|
||||
expect(updateEvent).toBeDefined();
|
||||
|
||||
const iterator = session
|
||||
.stream({ eventId: updateEvent!.id })
|
||||
[Symbol.asyncIterator]();
|
||||
await expect(iterator.next()).rejects.toThrow(
|
||||
`Cannot resume from eventId ${updateEvent!.id} before agent_start for stream ${updateEvent!.streamId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should replay from agent_start when resuming from a pre-agent_start event after activity is in history', async () => {
|
||||
const protocol = new MockAgentProtocol();
|
||||
const session = new AgentSession(protocol);
|
||||
|
||||
protocol.pushResponse([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
]);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'request' }],
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const userMessage = session.events.find(
|
||||
(event): event is AgentEvent<'message'> =>
|
||||
event.type === 'message' && event.role === 'user',
|
||||
);
|
||||
expect(userMessage).toBeDefined();
|
||||
|
||||
const streamedEvents: AgentEvent[] = [];
|
||||
for await (const event of session.stream({ eventId: userMessage!.id })) {
|
||||
streamedEvents.push(event);
|
||||
}
|
||||
|
||||
expect(streamedEvents.map((event) => event.type)).toEqual([
|
||||
'agent_start',
|
||||
'message',
|
||||
'agent_end',
|
||||
]);
|
||||
expect(streamedEvents[0]?.streamId).toBe(userMessage!.streamId);
|
||||
});
|
||||
|
||||
it('should throw when resuming from a pre-agent_start event before activity is in history', async () => {
|
||||
const protocol = new MockAgentProtocol([
|
||||
{
|
||||
id: 'e-1',
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
streamId: 'stream-1',
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'request' }],
|
||||
},
|
||||
]);
|
||||
const session = new AgentSession(protocol);
|
||||
|
||||
const iterator = session
|
||||
.stream({ eventId: 'e-1' })
|
||||
[Symbol.asyncIterator]();
|
||||
await expect(iterator.next()).rejects.toThrow(
|
||||
'Cannot resume from eventId e-1 before agent_start for stream stream-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should resume from an in-stream event within the same stream only', async () => {
|
||||
const protocol = new MockAgentProtocol();
|
||||
const session = new AgentSession(protocol);
|
||||
|
||||
protocol.pushResponse([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: 'first answer 1' }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: 'first answer 2' }],
|
||||
},
|
||||
]);
|
||||
const { streamId: streamId1 } = await session.send({
|
||||
message: [{ type: 'text', text: 'first request' }],
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
protocol.pushResponse([
|
||||
{
|
||||
type: 'message',
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: 'second answer' }],
|
||||
},
|
||||
]);
|
||||
await session.send({
|
||||
message: [{ type: 'text', text: 'second request' }],
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const resumeEvent = session.events.find(
|
||||
(event): event is AgentEvent<'message'> =>
|
||||
event.type === 'message' &&
|
||||
event.streamId === streamId1 &&
|
||||
event.role === 'agent' &&
|
||||
event.content[0]?.type === 'text' &&
|
||||
event.content[0].text === 'first answer 1',
|
||||
);
|
||||
expect(resumeEvent).toBeDefined();
|
||||
|
||||
const streamedEvents: AgentEvent[] = [];
|
||||
for await (const event of session.stream({ eventId: resumeEvent!.id })) {
|
||||
streamedEvents.push(event);
|
||||
}
|
||||
|
||||
expect(
|
||||
streamedEvents.every((event) => event.streamId === streamId1),
|
||||
).toBe(true);
|
||||
expect(streamedEvents.map((event) => event.type)).toEqual([
|
||||
'message',
|
||||
'agent_end',
|
||||
]);
|
||||
const resumedMessage = streamedEvents[0] as AgentEvent<'message'>;
|
||||
expect(resumedMessage.content).toEqual([
|
||||
{ type: 'text', text: 'first answer 2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should replay events for streamId starting with agent_start', async () => {
|
||||
const protocol = new MockAgentProtocol();
|
||||
const session = new AgentSession(protocol);
|
||||
@@ -225,6 +399,33 @@ describe('AgentSession', () => {
|
||||
expect(streamedEvents.at(-1)?.type).toBe('agent_end');
|
||||
});
|
||||
|
||||
it('should not drop agent_end that arrives while replay events are being yielded', async () => {
|
||||
const protocol = new MockAgentProtocol();
|
||||
const session = new AgentSession(protocol);
|
||||
|
||||
protocol.pushResponse([{ type: 'message' }], { keepOpen: true });
|
||||
const { streamId } = await session.send({ update: { title: 't1' } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const iterator = session
|
||||
.stream({ streamId: streamId! })
|
||||
[Symbol.asyncIterator]();
|
||||
|
||||
const first = await iterator.next();
|
||||
expect(first.value?.type).toBe('agent_start');
|
||||
|
||||
protocol.pushToStream(streamId!, [], { close: true });
|
||||
|
||||
const second = await iterator.next();
|
||||
expect(second.value?.type).toBe('message');
|
||||
|
||||
const third = await iterator.next();
|
||||
expect(third.value?.type).toBe('agent_end');
|
||||
|
||||
const fourth = await iterator.next();
|
||||
expect(fourth.done).toBe(true);
|
||||
});
|
||||
|
||||
it('should follow an active stream if no options provided', async () => {
|
||||
const protocol = new MockAgentProtocol();
|
||||
const session = new AgentSession(protocol);
|
||||
|
||||
@@ -34,7 +34,7 @@ export class AgentSession implements AgentProtocol {
|
||||
return this._protocol.abort();
|
||||
}
|
||||
|
||||
get events(): AgentEvent[] {
|
||||
get events(): readonly AgentEvent[] {
|
||||
return this._protocol.events;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,30 @@ export class AgentSession implements AgentProtocol {
|
||||
let done = false;
|
||||
let trackedStreamId = options.streamId;
|
||||
let started = false;
|
||||
let agentActivityStarted = false;
|
||||
|
||||
const queueVisibleEvent = (event: AgentEvent): void => {
|
||||
if (trackedStreamId && event.streamId !== trackedStreamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agentActivityStarted) {
|
||||
if (event.type !== 'agent_start') {
|
||||
return;
|
||||
}
|
||||
trackedStreamId = event.streamId;
|
||||
agentActivityStarted = true;
|
||||
}
|
||||
|
||||
if (!trackedStreamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventQueue.push(event);
|
||||
if (event.type === 'agent_end' && event.streamId === trackedStreamId) {
|
||||
done = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Subscribe early to avoid missing any events that occur during replay setup
|
||||
const unsubscribe = this._protocol.subscribe((event) => {
|
||||
@@ -87,23 +111,7 @@ export class AgentSession implements AgentProtocol {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trackedStreamId && event.streamId !== trackedStreamId) return;
|
||||
|
||||
// If we don't have a tracked stream yet, the first agent_start we see becomes it.
|
||||
if (!trackedStreamId && event.type === 'agent_start') {
|
||||
trackedStreamId = event.streamId ?? undefined;
|
||||
}
|
||||
|
||||
// If we still don't have a tracked stream and we aren't replaying everything (eventId), ignore.
|
||||
if (!trackedStreamId && !options.eventId) return;
|
||||
|
||||
eventQueue.push(event);
|
||||
if (
|
||||
event.type === 'agent_end' &&
|
||||
event.streamId === (trackedStreamId ?? null)
|
||||
) {
|
||||
done = true;
|
||||
}
|
||||
queueVisibleEvent(event);
|
||||
|
||||
const currentResolve = resolve;
|
||||
next = new Promise<void>((r) => {
|
||||
@@ -118,8 +126,42 @@ export class AgentSession implements AgentProtocol {
|
||||
|
||||
if (options.eventId) {
|
||||
const index = currentEvents.findIndex((e) => e.id === options.eventId);
|
||||
if (index !== -1) {
|
||||
if (index === -1) {
|
||||
throw new Error(`Unknown eventId: ${options.eventId}`);
|
||||
}
|
||||
|
||||
const resumeEvent = currentEvents[index];
|
||||
trackedStreamId = resumeEvent.streamId;
|
||||
const firstAgentStartIndex = currentEvents.findIndex(
|
||||
(event) =>
|
||||
event.type === 'agent_start' && event.streamId === trackedStreamId,
|
||||
);
|
||||
|
||||
if (resumeEvent.type === 'agent_end') {
|
||||
replayStartIndex = index + 1;
|
||||
agentActivityStarted = true;
|
||||
done = true;
|
||||
} else if (
|
||||
firstAgentStartIndex !== -1 &&
|
||||
firstAgentStartIndex <= index
|
||||
) {
|
||||
replayStartIndex = index + 1;
|
||||
agentActivityStarted = true;
|
||||
} else if (firstAgentStartIndex !== -1) {
|
||||
// A pre-agent_start cursor can be resumed once the corresponding
|
||||
// agent activity is already present in history. Because stream()
|
||||
// yields only agent_start -> agent_end, replay begins at agent_start
|
||||
// rather than at the original pre-start event.
|
||||
replayStartIndex = firstAgentStartIndex;
|
||||
} else {
|
||||
// Consumers can only resume by eventId once the corresponding stream
|
||||
// has entered the agent_start -> agent_end lifecycle in history.
|
||||
// Without a recorded agent_start, this wrapper cannot distinguish
|
||||
// "agent activity may start later" from "this send was acknowledged
|
||||
// without agent activity" without risking an infinite wait.
|
||||
throw new Error(
|
||||
`Cannot resume from eventId ${options.eventId} before agent_start for stream ${trackedStreamId}`,
|
||||
);
|
||||
}
|
||||
} else if (options.streamId) {
|
||||
const index = currentEvents.findIndex(
|
||||
@@ -128,29 +170,7 @@ export class AgentSession implements AgentProtocol {
|
||||
if (index !== -1) {
|
||||
replayStartIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (replayStartIndex !== -1) {
|
||||
for (let i = replayStartIndex; i < currentEvents.length; i++) {
|
||||
const event = currentEvents[i];
|
||||
if (options.streamId && event.streamId !== options.streamId) continue;
|
||||
|
||||
eventQueue.push(event);
|
||||
if (event.type === 'agent_start' && !trackedStreamId) {
|
||||
trackedStreamId = event.streamId ?? undefined;
|
||||
}
|
||||
if (
|
||||
event.type === 'agent_end' &&
|
||||
event.streamId === (trackedStreamId ?? null)
|
||||
) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!done && !trackedStreamId) {
|
||||
// Find active stream in history
|
||||
} else {
|
||||
const activeStarts = currentEvents.filter(
|
||||
(e) => e.type === 'agent_start',
|
||||
);
|
||||
@@ -161,36 +181,28 @@ export class AgentSession implements AgentProtocol {
|
||||
(e) => e.type === 'agent_end' && e.streamId === start.streamId,
|
||||
)
|
||||
) {
|
||||
trackedStreamId = start.streamId ?? undefined;
|
||||
trackedStreamId = start.streamId;
|
||||
replayStartIndex = currentEvents.findIndex(
|
||||
(e) => e.id === start.id,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we replayed to the end and no stream is active, and we were specifically
|
||||
// replaying from an eventId (or we've already finished the stream we were looking for), we are done.
|
||||
if (!done && !trackedStreamId && options.eventId) {
|
||||
done = true;
|
||||
if (replayStartIndex !== -1) {
|
||||
for (let i = replayStartIndex; i < currentEvents.length; i++) {
|
||||
const event = currentEvents[i];
|
||||
queueVisibleEvent(event);
|
||||
if (done) break;
|
||||
}
|
||||
}
|
||||
|
||||
started = true;
|
||||
|
||||
// Process events that arrived while we were replaying
|
||||
for (const event of earlyEvents) {
|
||||
if (done) break;
|
||||
if (trackedStreamId && event.streamId !== trackedStreamId) continue;
|
||||
if (!trackedStreamId && event.type === 'agent_start') {
|
||||
trackedStreamId = event.streamId ?? undefined;
|
||||
}
|
||||
if (!trackedStreamId && !options.eventId) continue;
|
||||
|
||||
eventQueue.push(event);
|
||||
if (
|
||||
event.type === 'agent_end' &&
|
||||
event.streamId === (trackedStreamId ?? null)
|
||||
) {
|
||||
done = true;
|
||||
}
|
||||
queueVisibleEvent(event);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
@@ -200,6 +212,7 @@ export class AgentSession implements AgentProtocol {
|
||||
for (const event of eventsToYield) {
|
||||
yield event;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (done) break;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* @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<string, string>;
|
||||
}
|
||||
|
||||
export function createTranslationState(streamId?: string): TranslationState {
|
||||
return {
|
||||
streamId: streamId ?? crypto.randomUUID(),
|
||||
streamStartEmitted: false,
|
||||
model: undefined,
|
||||
eventCounter: 0,
|
||||
pendingToolNames: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeEvent<T extends AgentEventType>(
|
||||
type: T,
|
||||
state: TranslationState,
|
||||
payload: Partial<AgentEvent<T>>,
|
||||
): AgentEvent {
|
||||
const id = `${state.streamId}-${state.eventCounter++}`;
|
||||
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<string, unknown> } {
|
||||
const meta: Record<string, unknown> = {};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview LegacyAgentSession backed by the existing Gemini client +
|
||||
* scheduler loop, adapted to the merged AgentProtocol / AgentSession surface.
|
||||
*/
|
||||
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
import type { Scheduler } from '../scheduler/scheduler.js';
|
||||
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
|
||||
import { ToolErrorType, isFatalToolError } from '../tools/tool-error.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
buildToolResponseData,
|
||||
contentPartsToGeminiParts,
|
||||
geminiPartsToContentParts,
|
||||
toolResultDisplayToContentParts,
|
||||
} from './content-utils.js';
|
||||
import { AgentSession } from './agent-session.js';
|
||||
import {
|
||||
createTranslationState,
|
||||
mapFinishReason,
|
||||
translateEvent,
|
||||
type TranslationState,
|
||||
} from './event-translator.js';
|
||||
import type {
|
||||
AgentEvent,
|
||||
AgentProtocol,
|
||||
AgentSend,
|
||||
ContentPart,
|
||||
StreamEndReason,
|
||||
Unsubscribe,
|
||||
} from './types.js';
|
||||
|
||||
function isAbortLikeError(err: unknown): boolean {
|
||||
return err instanceof Error && err.name === 'AbortError';
|
||||
}
|
||||
|
||||
export interface LegacySessionDeps {
|
||||
client: GeminiClient;
|
||||
scheduler: Scheduler;
|
||||
config: Config;
|
||||
promptId: string;
|
||||
streamId?: string;
|
||||
}
|
||||
|
||||
class LegacyAgentProtocol implements AgentProtocol {
|
||||
private _events: AgentEvent[] = [];
|
||||
private _subscribers = new Set<(event: AgentEvent) => void>();
|
||||
private _translationState: TranslationState;
|
||||
private _agentEndEmitted = false;
|
||||
private _activeStreamId?: string;
|
||||
private _abortController = new AbortController();
|
||||
private _nextStreamIdOverride?: string;
|
||||
|
||||
private readonly _client: GeminiClient;
|
||||
private readonly _scheduler: Scheduler;
|
||||
private readonly _config: Config;
|
||||
private readonly _promptId: string;
|
||||
|
||||
constructor(deps: LegacySessionDeps) {
|
||||
this._translationState = createTranslationState(deps.streamId);
|
||||
this._nextStreamIdOverride = deps.streamId;
|
||||
this._client = deps.client;
|
||||
this._scheduler = deps.scheduler;
|
||||
this._config = deps.config;
|
||||
this._promptId = deps.promptId;
|
||||
}
|
||||
|
||||
get events(): readonly AgentEvent[] {
|
||||
return this._events;
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentEvent) => void): Unsubscribe {
|
||||
this._subscribers.add(callback);
|
||||
return () => {
|
||||
this._subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async send(payload: AgentSend): Promise<{ streamId: string }> {
|
||||
const message = 'message' in payload ? payload.message : undefined;
|
||||
if (!message) {
|
||||
throw new Error(
|
||||
'LegacyAgentSession.send() only supports message sends for the moment.',
|
||||
);
|
||||
}
|
||||
|
||||
if (this._activeStreamId) {
|
||||
// TODO: Interactive may eventually allow selected in-stream sends such as
|
||||
// updates or elicitation responses. Keep rejecting all concurrent sends
|
||||
// here until we define those correlation semantics.
|
||||
throw new Error(
|
||||
'LegacyAgentSession.send() cannot be called while a stream is active.',
|
||||
);
|
||||
}
|
||||
|
||||
this._beginNewStream();
|
||||
const streamId = this._translationState.streamId;
|
||||
const parts = contentPartsToGeminiParts(message);
|
||||
const userMessage = this._makeUserMessageEvent(message, payload._meta);
|
||||
|
||||
this._emit([userMessage]);
|
||||
|
||||
this._scheduleRunLoop(parts);
|
||||
|
||||
return { streamId };
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this._abortController.abort();
|
||||
}
|
||||
|
||||
private _scheduleRunLoop(initialParts: Part[]): void {
|
||||
setTimeout(() => {
|
||||
void this._runLoopInBackground(initialParts);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private async _runLoopInBackground(initialParts: Part[]): Promise<void> {
|
||||
this._ensureAgentStart();
|
||||
try {
|
||||
await this._runLoop(initialParts);
|
||||
} catch (err: unknown) {
|
||||
if (this._abortController.signal.aborted || isAbortLikeError(err)) {
|
||||
this._ensureAgentEnd('aborted');
|
||||
} else {
|
||||
this._emitErrorAndAgentEnd(err);
|
||||
}
|
||||
this._markStreamDone();
|
||||
}
|
||||
}
|
||||
|
||||
private async _runLoop(initialParts: Part[]): Promise<void> {
|
||||
let currentParts: Part[] = initialParts;
|
||||
let turnCount = 0;
|
||||
const maxTurns = this._config.getMaxSessionTurns();
|
||||
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (maxTurns >= 0 && turnCount > maxTurns) {
|
||||
this._finishStream('max_turns', {
|
||||
code: 'MAX_TURNS_EXCEEDED',
|
||||
maxTurns,
|
||||
turnCount: turnCount - 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
const responseStream = this._client.sendMessageStream(
|
||||
currentParts,
|
||||
this._abortController.signal,
|
||||
this._promptId,
|
||||
);
|
||||
|
||||
for await (const event of responseStream) {
|
||||
if (this._abortController.signal.aborted) {
|
||||
this._finishStream('aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
}
|
||||
|
||||
this._emit(translateEvent(event, this._translationState));
|
||||
|
||||
if (event.type === GeminiEventType.Error) {
|
||||
this._finishStream('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === GeminiEventType.InvalidStream ||
|
||||
event.type === GeminiEventType.ContextWindowWillOverflow
|
||||
) {
|
||||
this._finishStream('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === GeminiEventType.Finished) {
|
||||
if (toolCallRequests.length === 0) {
|
||||
this._finishStream(mapFinishReason(event.value.reason));
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === GeminiEventType.AgentExecutionStopped ||
|
||||
event.type === GeminiEventType.UserCancelled ||
|
||||
event.type === GeminiEventType.MaxSessionTurns
|
||||
) {
|
||||
this._markStreamDone();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._abortController.signal.aborted) {
|
||||
this._finishStream('aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolCallRequests.length === 0) {
|
||||
this._finishStream('completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const completedToolCalls = await this._scheduler.schedule(
|
||||
toolCallRequests,
|
||||
this._abortController.signal,
|
||||
);
|
||||
|
||||
if (this._abortController.signal.aborted) {
|
||||
this._finishStream('aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
const toolResponseParts: Part[] = [];
|
||||
for (const tc of completedToolCalls) {
|
||||
const response = tc.response;
|
||||
const request = tc.request;
|
||||
const content: ContentPart[] = response.error
|
||||
? [{ type: 'text', text: response.error.message }]
|
||||
: geminiPartsToContentParts(response.responseParts);
|
||||
const displayContent = toolResultDisplayToContentParts(
|
||||
response.resultDisplay,
|
||||
);
|
||||
const data = buildToolResponseData(response);
|
||||
|
||||
this._emit([
|
||||
this._makeToolResponseEvent({
|
||||
requestId: request.callId,
|
||||
name: request.name,
|
||||
content,
|
||||
isError: response.error !== undefined,
|
||||
...(displayContent ? { displayContent } : {}),
|
||||
...(data ? { data } : {}),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (response.responseParts) {
|
||||
toolResponseParts.push(...response.responseParts);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const currentModel =
|
||||
this._client.getCurrentSequenceModel() ?? this._config.getModel();
|
||||
this._client
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(currentModel, completedToolCalls);
|
||||
await recordToolCallInteractions(this._config, completedToolCalls);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error recording completed tool call information: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stopTool = completedToolCalls.find(
|
||||
(tc) =>
|
||||
tc.response.errorType === ToolErrorType.STOP_EXECUTION &&
|
||||
tc.response.error !== undefined,
|
||||
);
|
||||
if (stopTool) {
|
||||
this._finishStream('completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const fatalTool = completedToolCalls.find((tc) =>
|
||||
isFatalToolError(tc.response.errorType),
|
||||
);
|
||||
if (fatalTool) {
|
||||
this._finishStream('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
currentParts = toolResponseParts;
|
||||
}
|
||||
}
|
||||
|
||||
private _emit(events: AgentEvent[]): void {
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscribers = [...this._subscribers];
|
||||
for (const event of events) {
|
||||
if (!this._events.some((existing) => existing.id === event.id)) {
|
||||
this._events.push(event);
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
this._agentEndEmitted = true;
|
||||
}
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _markStreamDone(): void {
|
||||
this._activeStreamId = undefined;
|
||||
}
|
||||
|
||||
private _beginNewStream(): void {
|
||||
this._translationState = createTranslationState(this._nextStreamIdOverride);
|
||||
this._nextStreamIdOverride = undefined;
|
||||
this._abortController = new AbortController();
|
||||
this._agentEndEmitted = false;
|
||||
this._activeStreamId = this._translationState.streamId;
|
||||
}
|
||||
|
||||
private _ensureAgentStart(): void {
|
||||
if (!this._translationState.streamStartEmitted) {
|
||||
this._translationState.streamStartEmitted = true;
|
||||
this._emit([this._makeAgentStartEvent()]);
|
||||
}
|
||||
}
|
||||
|
||||
private _ensureAgentEnd(reason: StreamEndReason = 'completed'): void {
|
||||
if (!this._agentEndEmitted && this._translationState.streamStartEmitted) {
|
||||
this._agentEndEmitted = true;
|
||||
this._emit([this._makeAgentEndEvent(reason)]);
|
||||
}
|
||||
}
|
||||
|
||||
private _finishStream(
|
||||
reason: StreamEndReason,
|
||||
data?: Record<string, unknown>,
|
||||
): void {
|
||||
if (data && !this._agentEndEmitted) {
|
||||
this._emit([this._makeAgentEndEvent(reason, data)]);
|
||||
} else {
|
||||
this._ensureAgentEnd(reason);
|
||||
}
|
||||
this._markStreamDone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve error identity fields in _meta so downstream consumers can
|
||||
* reconstruct fatal CLI errors.
|
||||
*/
|
||||
private _emitErrorAndAgentEnd(err: unknown): void {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
this._ensureAgentStart();
|
||||
|
||||
const meta: Record<string, unknown> = {};
|
||||
if (err instanceof Error) {
|
||||
meta['errorName'] = err.constructor.name;
|
||||
if ('exitCode' in err && typeof err.exitCode === 'number') {
|
||||
meta['exitCode'] = err.exitCode;
|
||||
}
|
||||
if ('code' in err) {
|
||||
meta['code'] = err.code;
|
||||
}
|
||||
if ('status' in err) {
|
||||
meta['status'] = err.status;
|
||||
}
|
||||
}
|
||||
|
||||
this._emit([
|
||||
this._makeErrorEvent({
|
||||
status: 'INTERNAL',
|
||||
message,
|
||||
fatal: true,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
}),
|
||||
]);
|
||||
|
||||
this._ensureAgentEnd('failed');
|
||||
}
|
||||
|
||||
private _nextEventFields() {
|
||||
return {
|
||||
id: `${this._translationState.streamId}-${this._translationState.eventCounter++}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
streamId: this._translationState.streamId,
|
||||
};
|
||||
}
|
||||
|
||||
private _makeUserMessageEvent(
|
||||
content: ContentPart[],
|
||||
meta?: Record<string, unknown>,
|
||||
): AgentEvent<'message'> {
|
||||
const event = {
|
||||
...this._nextEventFields(),
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content,
|
||||
...(meta ? { _meta: meta } : {}),
|
||||
} satisfies AgentEvent<'message'>;
|
||||
return event;
|
||||
}
|
||||
|
||||
private _makeToolResponseEvent(
|
||||
payload: Omit<
|
||||
AgentEvent<'tool_response'>,
|
||||
'id' | 'timestamp' | 'streamId' | 'type'
|
||||
>,
|
||||
): AgentEvent<'tool_response'> {
|
||||
const event = {
|
||||
...this._nextEventFields(),
|
||||
type: 'tool_response',
|
||||
...payload,
|
||||
} satisfies AgentEvent<'tool_response'>;
|
||||
return event;
|
||||
}
|
||||
|
||||
private _makeAgentStartEvent(): AgentEvent<'agent_start'> {
|
||||
const event = {
|
||||
...this._nextEventFields(),
|
||||
type: 'agent_start',
|
||||
} satisfies AgentEvent<'agent_start'>;
|
||||
return event;
|
||||
}
|
||||
|
||||
private _makeAgentEndEvent(
|
||||
reason: StreamEndReason,
|
||||
data?: Record<string, unknown>,
|
||||
): AgentEvent<'agent_end'> {
|
||||
const event = {
|
||||
...this._nextEventFields(),
|
||||
type: 'agent_end',
|
||||
reason,
|
||||
...(data ? { data } : {}),
|
||||
} satisfies AgentEvent<'agent_end'>;
|
||||
return event;
|
||||
}
|
||||
|
||||
private _makeErrorEvent(
|
||||
payload: Omit<
|
||||
AgentEvent<'error'>,
|
||||
'id' | 'timestamp' | 'streamId' | 'type'
|
||||
>,
|
||||
): AgentEvent<'error'> {
|
||||
const event = {
|
||||
...this._nextEventFields(),
|
||||
type: 'error',
|
||||
...payload,
|
||||
} satisfies AgentEvent<'error'>;
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
export class LegacyAgentSession extends AgentSession {
|
||||
constructor(deps: LegacySessionDeps) {
|
||||
super(new LegacyAgentProtocol(deps));
|
||||
}
|
||||
}
|
||||
@@ -235,7 +235,7 @@ describe('MockAgentProtocol', () => {
|
||||
expect(streamId).toBeNull();
|
||||
expect(session.events).toHaveLength(1);
|
||||
expect(session.events[0].type).toBe('session_update');
|
||||
expect(session.events[0].streamId).toBeNull();
|
||||
expect(session.events[0].streamId).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('should throw on action', async () => {
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
AgentEvent,
|
||||
AgentEventCommon,
|
||||
AgentEventData,
|
||||
AgentSend,
|
||||
AgentProtocol,
|
||||
AgentSend,
|
||||
Unsubscribe,
|
||||
} from './types.js';
|
||||
|
||||
@@ -86,12 +86,7 @@ export class MockAgentProtocol implements AgentProtocol {
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
for (const eventData of events) {
|
||||
const event: AgentEvent = {
|
||||
...eventData,
|
||||
id: eventData.id ?? `e-${this._nextEventId++}`,
|
||||
timestamp: eventData.timestamp ?? now,
|
||||
streamId: eventData.streamId ?? streamId,
|
||||
} as AgentEvent;
|
||||
const event = this._normalizeEvent(eventData, now, streamId);
|
||||
this._emit(event);
|
||||
}
|
||||
|
||||
@@ -99,13 +94,14 @@ export class MockAgentProtocol implements AgentProtocol {
|
||||
options?.close &&
|
||||
!events.some((eventData) => eventData.type === 'agent_end')
|
||||
) {
|
||||
this._emit({
|
||||
const endEvent = {
|
||||
id: `e-${this._nextEventId++}`,
|
||||
timestamp: now,
|
||||
streamId,
|
||||
type: 'agent_end',
|
||||
reason: 'completed',
|
||||
} as AgentEvent);
|
||||
} satisfies AgentEvent<'agent_end'>;
|
||||
this._emit(endEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,21 +113,19 @@ export class MockAgentProtocol implements AgentProtocol {
|
||||
|
||||
// If there were queued responses (even if empty array), we trigger a stream.
|
||||
const hasResponseEvents = responseData !== undefined;
|
||||
const streamId = hasResponseEvents
|
||||
? (response[0]?.streamId ?? `mock-stream-${this._nextStreamId++}`)
|
||||
: null;
|
||||
let correlationStreamId: string | undefined;
|
||||
const getCorrelationStreamId = (): string =>
|
||||
(correlationStreamId ??=
|
||||
response[0]?.streamId ?? `mock-stream-${this._nextStreamId++}`);
|
||||
const streamId = hasResponseEvents ? getCorrelationStreamId() : null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const eventsToEmit: AgentEvent[] = [];
|
||||
|
||||
// Helper to normalize and prepare for emission
|
||||
// All emitted events stay correlated to a stream even if this send does not
|
||||
// start agent activity and therefore returns `streamId: null`.
|
||||
const normalize = (eventData: MockAgentEvent): AgentEvent =>
|
||||
({
|
||||
...eventData,
|
||||
id: eventData.id ?? `e-${this._nextEventId++}`,
|
||||
timestamp: eventData.timestamp ?? now,
|
||||
streamId: eventData.streamId ?? streamId,
|
||||
}) as AgentEvent;
|
||||
this._normalizeEvent(eventData, now, getCorrelationStreamId());
|
||||
|
||||
// 1. User/Update event (BEFORE agent_start)
|
||||
if ('message' in payload && payload.message) {
|
||||
@@ -223,16 +217,33 @@ export class MockAgentProtocol implements AgentProtocol {
|
||||
return { streamId };
|
||||
}
|
||||
|
||||
private _normalizeEvent(
|
||||
eventData: MockAgentEvent,
|
||||
timestamp: string,
|
||||
streamId: string,
|
||||
): AgentEvent {
|
||||
// TypeScript loses the specific union member when we add common event
|
||||
// fields here, so keep the narrowing local to this mock-only helper.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
...eventData,
|
||||
id: eventData.id ?? `e-${this._nextEventId++}`,
|
||||
timestamp: eventData.timestamp ?? timestamp,
|
||||
streamId: eventData.streamId ?? streamId,
|
||||
} as AgentEvent;
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
if (this._lastStreamId && this._activeStreamIds.has(this._lastStreamId)) {
|
||||
const streamId = this._lastStreamId;
|
||||
this._emit({
|
||||
const endEvent = {
|
||||
id: `e-${this._nextEventId++}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
streamId,
|
||||
type: 'agent_end',
|
||||
reason: 'aborted',
|
||||
} as AgentEvent);
|
||||
} satisfies AgentEvent<'agent_end'>;
|
||||
this._emit(endEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ export type Unsubscribe = () => void;
|
||||
export interface AgentProtocol extends Trajectory {
|
||||
/**
|
||||
* Send data to the agent. Promise resolves when action is acknowledged.
|
||||
* Returns the `streamId` of the stream the message was correlated to --
|
||||
* this may be a new stream if idle, an existing stream, or null if no
|
||||
* stream was triggered.
|
||||
* Returns the agent-activity `streamId` affected by the send. This may be a
|
||||
* new stream if idle, an existing stream, or null if the send was
|
||||
* acknowledged without starting agent activity. Emitted events should still
|
||||
* remain correlated to a stream via their `streamId`.
|
||||
*
|
||||
* When a new stream is created by a send, the streamId MUST be returned
|
||||
* before the `agent_start` event is emitted for the stream.
|
||||
@@ -36,7 +37,7 @@ export interface AgentProtocol extends Trajectory {
|
||||
/**
|
||||
* AgentProtocol implements the Trajectory interface and can retrieve existing events.
|
||||
*/
|
||||
readonly events: AgentEvent[];
|
||||
readonly events: readonly AgentEvent[];
|
||||
}
|
||||
|
||||
type RequireExactlyOne<T> = {
|
||||
@@ -54,7 +55,7 @@ interface AgentSendPayloads {
|
||||
export type AgentSend = RequireExactlyOne<AgentSendPayloads> & WithMeta;
|
||||
|
||||
export interface Trajectory {
|
||||
readonly events: AgentEvent[];
|
||||
readonly events: readonly AgentEvent[];
|
||||
}
|
||||
|
||||
export interface AgentEventCommon {
|
||||
@@ -62,8 +63,8 @@ export interface AgentEventCommon {
|
||||
id: string;
|
||||
/** Identifies the subagent thread, omitted for "main thread" events. */
|
||||
threadId?: string;
|
||||
/** Identifies a particular stream of a particular thread. */
|
||||
streamId?: string | null;
|
||||
/** Identifies the stream this event belongs to. */
|
||||
streamId: string;
|
||||
/** ISO Timestamp for the time at which the event occurred. */
|
||||
timestamp: string;
|
||||
/** The concrete type of the event. */
|
||||
@@ -81,9 +82,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<EventType>;
|
||||
> = {
|
||||
[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 +273,7 @@ export interface AgentStart {
|
||||
streamId: string;
|
||||
}
|
||||
|
||||
type StreamEndReason =
|
||||
export type StreamEndReason =
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'aborted'
|
||||
|
||||
@@ -181,6 +181,31 @@ export * from './agents/agentLoader.js';
|
||||
export * from './agents/local-executor.js';
|
||||
export * from './agents/agent-scheduler.js';
|
||||
|
||||
// Export agent session interface
|
||||
export * from './agent/agent-session.js';
|
||||
export * from './agent/legacy-agent-session.js';
|
||||
export * from './agent/event-translator.js';
|
||||
export * from './agent/content-utils.js';
|
||||
// Agent event types — namespaced to avoid collisions with existing exports
|
||||
export type {
|
||||
AgentEvent,
|
||||
AgentEventCommon,
|
||||
AgentEventData,
|
||||
AgentEnd,
|
||||
AgentEvents as AgentEventMap,
|
||||
AgentEventType,
|
||||
AgentProtocol,
|
||||
AgentSend,
|
||||
AgentStart,
|
||||
ContentPart,
|
||||
ErrorData,
|
||||
StreamEndReason,
|
||||
Trajectory,
|
||||
Unsubscribe,
|
||||
Usage as AgentUsage,
|
||||
WithMeta,
|
||||
} from './agent/types.js';
|
||||
|
||||
// Export specific tool logic
|
||||
export * from './tools/read-file.js';
|
||||
export * from './tools/ls.js';
|
||||
|
||||
Reference in New Issue
Block a user