Compare commits

...

15 Commits

Author SHA1 Message Date
Adam Weidman 14fe9815d0 fix(core): align wrapper replay semantics 2026-03-23 11:42:46 -04:00
Adam Weidman 2582669d62 fix(core): align legacy session tests with loop warnings 2026-03-23 10:58:41 -04:00
Adam Weidman 7abec8e3c7 !feat(core): tighten legacy agent session stream lifecycle 2026-03-23 10:57:55 -04:00
Adam Weidman f038ba417f !feat(core): adapt legacy session to merged agent session 2026-03-23 10:57:40 -04:00
Adam Weidman 1ee198cc2c !feat(core): clarify agent session resume boundaries 2026-03-23 10:57:21 -04:00
Adam Weidman 18544cc994 !feat(core): clarify mock stream event typing 2026-03-23 10:57:20 -04:00
Adam Weidman 139a5f3202 !feat(core): correlate all protocol events to streams 2026-03-23 10:56:52 -04:00
Adam Weidman db0624c6cd !feat(core): harden agent session stream semantics 2026-03-23 10:56:52 -04:00
Adam Weidman 1947629a38 !feat(core): harden agent session stream semantics 2026-03-23 10:56:52 -04:00
Adam Weidman 3878599772 !feat(core): surface loop detection as a warning 2026-03-23 09:57:45 -04:00
Adam Weidman db35ecd705 fix: resolve inherited strict typing violations 2026-03-20 11:59:02 -04:00
Adam Weidman 1b6c609165 refactor(core): end sessions after finished turns 2026-03-20 10:58:58 -04:00
Adam Weidman 6c9f644417 refactor(core): treat max session turns as stream end 2026-03-20 10:58:58 -04:00
Adam Weidman cc2058296c fix: address easy code review comments for event-translator 2026-03-20 10:58:58 -04:00
Adam Weidman fb1b814ff8 feat(core): add event-translator and update agent types 2026-03-20 10:58:58 -04:00
10 changed files with 3417 additions and 95 deletions
+204 -3
View File
@@ -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);
+74 -61
View File
@@ -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');
});
});
+453
View File
@@ -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));
}
}
+1 -1
View File
@@ -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 () => {
+32 -21
View File
@@ -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);
}
}
}
+19 -9
View File
@@ -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'
+25
View File
@@ -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';