2026-03-16 10:59:02 -07:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2026 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, expect, it } from 'vitest';
|
2026-03-20 06:40:10 -07:00
|
|
|
import { MockAgentProtocol } from './mock.js';
|
|
|
|
|
import type { AgentEvent, AgentProtocol } from './types.js';
|
2026-03-16 10:59:02 -07:00
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const waitForStreamEnd = (session: AgentProtocol): Promise<AgentEvent[]> =>
|
|
|
|
|
new Promise((resolve) => {
|
|
|
|
|
const events: AgentEvent[] = [];
|
|
|
|
|
const unsubscribe = session.subscribe((e) => {
|
|
|
|
|
events.push(e);
|
|
|
|
|
if (e.type === 'agent_end') {
|
|
|
|
|
unsubscribe();
|
|
|
|
|
resolve(events);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('MockAgentProtocol', () => {
|
|
|
|
|
it('should emit queued events on send and subscribe', async () => {
|
|
|
|
|
const session = new MockAgentProtocol();
|
2026-03-16 10:59:02 -07:00
|
|
|
const event1 = {
|
|
|
|
|
type: 'message',
|
|
|
|
|
role: 'agent',
|
|
|
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
|
|
|
} as AgentEvent;
|
|
|
|
|
|
|
|
|
|
session.pushResponse([event1]);
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const streamPromise = waitForStreamEnd(session);
|
|
|
|
|
|
2026-03-16 10:59:02 -07:00
|
|
|
const { streamId } = await session.send({
|
2026-04-02 16:21:40 -04:00
|
|
|
message: { content: [{ type: 'text', text: 'hi' }] },
|
2026-03-16 10:59:02 -07:00
|
|
|
});
|
|
|
|
|
expect(streamId).toBeDefined();
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const streamedEvents = await streamPromise;
|
2026-03-16 10:59:02 -07:00
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
// Ordered: user message, agent_start, agent message, agent_end = 4 events
|
2026-03-16 10:59:02 -07:00
|
|
|
expect(streamedEvents).toHaveLength(4);
|
2026-03-20 06:40:10 -07:00
|
|
|
expect(streamedEvents[0].type).toBe('message');
|
|
|
|
|
expect((streamedEvents[0] as AgentEvent<'message'>).role).toBe('user');
|
|
|
|
|
expect(streamedEvents[1].type).toBe('agent_start');
|
2026-03-16 10:59:02 -07:00
|
|
|
expect(streamedEvents[2].type).toBe('message');
|
|
|
|
|
expect((streamedEvents[2] as AgentEvent<'message'>).role).toBe('agent');
|
2026-03-20 06:40:10 -07:00
|
|
|
expect(streamedEvents[3].type).toBe('agent_end');
|
2026-03-16 10:59:02 -07:00
|
|
|
|
|
|
|
|
expect(session.events).toHaveLength(4);
|
|
|
|
|
expect(session.events).toEqual(streamedEvents);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle multiple responses', async () => {
|
2026-03-20 06:40:10 -07:00
|
|
|
const session = new MockAgentProtocol();
|
2026-03-16 10:59:02 -07:00
|
|
|
|
|
|
|
|
// Test with empty payload (no message injected)
|
|
|
|
|
session.pushResponse([]);
|
|
|
|
|
session.pushResponse([
|
|
|
|
|
{
|
|
|
|
|
type: 'error',
|
|
|
|
|
message: 'fail',
|
|
|
|
|
fatal: true,
|
|
|
|
|
status: 'RESOURCE_EXHAUSTED',
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// First send
|
2026-03-20 06:40:10 -07:00
|
|
|
const stream1Promise = waitForStreamEnd(session);
|
2026-03-16 10:59:02 -07:00
|
|
|
const { streamId: s1 } = await session.send({
|
2026-03-20 06:40:10 -07:00
|
|
|
update: { title: 't1' },
|
2026-03-16 10:59:02 -07:00
|
|
|
});
|
2026-03-20 06:40:10 -07:00
|
|
|
const events1 = await stream1Promise;
|
|
|
|
|
expect(events1).toHaveLength(3); // session_update, agent_start, agent_end
|
|
|
|
|
expect(events1[0].type).toBe('session_update');
|
|
|
|
|
expect(events1[1].type).toBe('agent_start');
|
|
|
|
|
expect(events1[2].type).toBe('agent_end');
|
2026-03-16 10:59:02 -07:00
|
|
|
|
|
|
|
|
// Second send
|
2026-03-20 06:40:10 -07:00
|
|
|
const stream2Promise = waitForStreamEnd(session);
|
2026-03-16 10:59:02 -07:00
|
|
|
const { streamId: s2 } = await session.send({
|
2026-03-20 06:40:10 -07:00
|
|
|
update: { title: 't2' },
|
2026-03-16 10:59:02 -07:00
|
|
|
});
|
|
|
|
|
expect(s1).not.toBe(s2);
|
2026-03-20 06:40:10 -07:00
|
|
|
const events2 = await stream2Promise;
|
|
|
|
|
expect(events2).toHaveLength(4); // session_update, agent_start, error, agent_end
|
|
|
|
|
expect(events2[0].type).toBe('session_update');
|
|
|
|
|
expect(events2[1].type).toBe('agent_start');
|
2026-03-16 10:59:02 -07:00
|
|
|
expect(events2[2].type).toBe('error');
|
2026-03-20 06:40:10 -07:00
|
|
|
expect(events2[3].type).toBe('agent_end');
|
2026-03-16 10:59:02 -07:00
|
|
|
|
|
|
|
|
expect(session.events).toHaveLength(7);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
it('should handle abort on a waiting stream', async () => {
|
|
|
|
|
const session = new MockAgentProtocol();
|
|
|
|
|
// Use keepOpen to prevent auto agent_end
|
|
|
|
|
session.pushResponse([{ type: 'message' }], { keepOpen: true });
|
2026-03-16 10:59:02 -07:00
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const events: AgentEvent[] = [];
|
|
|
|
|
let resolveStream: (evs: AgentEvent[]) => void;
|
|
|
|
|
const streamPromise = new Promise<AgentEvent[]>((res) => {
|
|
|
|
|
resolveStream = res;
|
2026-03-16 10:59:02 -07:00
|
|
|
});
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
session.subscribe((e) => {
|
2026-03-16 10:59:02 -07:00
|
|
|
events.push(e);
|
2026-03-20 06:40:10 -07:00
|
|
|
if (e.type === 'agent_end') {
|
|
|
|
|
resolveStream(events);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-03-16 10:59:02 -07:00
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const { streamId: _streamId } = await session.send({
|
|
|
|
|
update: { title: 't' },
|
|
|
|
|
});
|
2026-03-16 10:59:02 -07:00
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
// Initial events should have been emitted
|
|
|
|
|
expect(events.map((e) => e.type)).toEqual([
|
|
|
|
|
'session_update',
|
|
|
|
|
'agent_start',
|
|
|
|
|
'message',
|
|
|
|
|
]);
|
2026-03-16 10:59:02 -07:00
|
|
|
|
|
|
|
|
// At this point, the stream should be "waiting" for more events because it's still active
|
2026-03-20 06:40:10 -07:00
|
|
|
// and hasn't seen an agent_end.
|
|
|
|
|
await session.abort();
|
|
|
|
|
|
|
|
|
|
const finalEvents = await streamPromise;
|
|
|
|
|
expect(finalEvents[3].type).toBe('agent_end');
|
|
|
|
|
expect((finalEvents[3] as AgentEvent<'agent_end'>).reason).toBe('aborted');
|
2026-03-16 10:59:02 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle pushToStream on a waiting stream', async () => {
|
2026-03-20 06:40:10 -07:00
|
|
|
const session = new MockAgentProtocol();
|
2026-03-16 10:59:02 -07:00
|
|
|
session.pushResponse([], { keepOpen: true });
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const events: AgentEvent[] = [];
|
|
|
|
|
session.subscribe((e) => events.push(e));
|
|
|
|
|
|
|
|
|
|
const { streamId } = await session.send({ update: { title: 't' } });
|
|
|
|
|
|
|
|
|
|
expect(events.map((e) => e.type)).toEqual([
|
|
|
|
|
'session_update',
|
|
|
|
|
'agent_start',
|
|
|
|
|
]);
|
2026-03-16 10:59:02 -07:00
|
|
|
|
|
|
|
|
// Push new event to active stream
|
2026-03-20 06:40:10 -07:00
|
|
|
session.pushToStream(streamId!, [{ type: 'message' }]);
|
2026-03-16 10:59:02 -07:00
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
expect(events).toHaveLength(3);
|
|
|
|
|
expect(events[2].type).toBe('message');
|
2026-03-16 10:59:02 -07:00
|
|
|
|
|
|
|
|
await session.abort();
|
2026-03-20 06:40:10 -07:00
|
|
|
expect(events).toHaveLength(4);
|
|
|
|
|
expect(events[3].type).toBe('agent_end');
|
2026-03-16 10:59:02 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle pushToStream with close option', async () => {
|
2026-03-20 06:40:10 -07:00
|
|
|
const session = new MockAgentProtocol();
|
2026-03-16 10:59:02 -07:00
|
|
|
session.pushResponse([], { keepOpen: true });
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const streamPromise = waitForStreamEnd(session);
|
|
|
|
|
const { streamId } = await session.send({ update: { title: 't' } });
|
2026-03-16 10:59:02 -07:00
|
|
|
|
|
|
|
|
// Push new event and close
|
2026-03-20 06:40:10 -07:00
|
|
|
session.pushToStream(streamId!, [{ type: 'message' }], { close: true });
|
|
|
|
|
|
|
|
|
|
const events = await streamPromise;
|
|
|
|
|
expect(events.map((e) => e.type)).toEqual([
|
|
|
|
|
'session_update',
|
|
|
|
|
'agent_start',
|
|
|
|
|
'message',
|
|
|
|
|
'agent_end',
|
|
|
|
|
]);
|
|
|
|
|
expect((events[3] as AgentEvent<'agent_end'>).reason).toBe('completed');
|
2026-03-16 10:59:02 -07:00
|
|
|
});
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
it('should not double up on agent_end if provided manually', async () => {
|
|
|
|
|
const session = new MockAgentProtocol();
|
2026-03-16 10:59:02 -07:00
|
|
|
session.pushResponse([
|
|
|
|
|
{ type: 'message' },
|
2026-03-20 06:40:10 -07:00
|
|
|
{ type: 'agent_end', reason: 'completed' },
|
2026-03-16 10:59:02 -07:00
|
|
|
]);
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const streamPromise = waitForStreamEnd(session);
|
|
|
|
|
await session.send({ update: { title: 't' } });
|
2026-03-16 10:59:02 -07:00
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const events = await streamPromise;
|
|
|
|
|
const endEvents = events.filter((e) => e.type === 'agent_end');
|
2026-03-16 10:59:02 -07:00
|
|
|
expect(endEvents).toHaveLength(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle elicitations', async () => {
|
2026-03-20 06:40:10 -07:00
|
|
|
const session = new MockAgentProtocol();
|
2026-03-16 10:59:02 -07:00
|
|
|
session.pushResponse([]);
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const streamPromise = waitForStreamEnd(session);
|
2026-03-16 10:59:02 -07:00
|
|
|
await session.send({
|
|
|
|
|
elicitations: [
|
|
|
|
|
{ requestId: 'r1', action: 'accept', content: { foo: 'bar' } },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const events = await streamPromise;
|
|
|
|
|
expect(events[0].type).toBe('elicitation_response');
|
|
|
|
|
expect((events[0] as AgentEvent<'elicitation_response'>).requestId).toBe(
|
2026-03-16 10:59:02 -07:00
|
|
|
'r1',
|
|
|
|
|
);
|
2026-03-20 06:40:10 -07:00
|
|
|
expect(events[1].type).toBe('agent_start');
|
2026-03-16 10:59:02 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle updates and track state', async () => {
|
2026-03-20 06:40:10 -07:00
|
|
|
const session = new MockAgentProtocol();
|
2026-03-16 10:59:02 -07:00
|
|
|
session.pushResponse([]);
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const streamPromise = waitForStreamEnd(session);
|
2026-03-16 10:59:02 -07:00
|
|
|
await session.send({
|
|
|
|
|
update: { title: 'New Title', model: 'gpt-4', config: { x: 1 } },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(session.title).toBe('New Title');
|
|
|
|
|
expect(session.model).toBe('gpt-4');
|
|
|
|
|
expect(session.config).toEqual({ x: 1 });
|
|
|
|
|
|
2026-03-20 06:40:10 -07:00
|
|
|
const events = await streamPromise;
|
|
|
|
|
expect(events[0].type).toBe('session_update');
|
|
|
|
|
expect(events[1].type).toBe('agent_start');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return streamId: null if no response queued', async () => {
|
|
|
|
|
const session = new MockAgentProtocol();
|
|
|
|
|
const { streamId } = await session.send({ update: { title: 'foo' } });
|
|
|
|
|
expect(streamId).toBeNull();
|
|
|
|
|
expect(session.events).toHaveLength(1);
|
|
|
|
|
expect(session.events[0].type).toBe('session_update');
|
2026-03-23 14:43:38 -04:00
|
|
|
expect(session.events[0].streamId).toEqual(expect.any(String));
|
2026-03-16 10:59:02 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should throw on action', async () => {
|
2026-03-20 06:40:10 -07:00
|
|
|
const session = new MockAgentProtocol();
|
2026-03-16 10:59:02 -07:00
|
|
|
await expect(
|
|
|
|
|
session.send({ action: { type: 'foo', data: {} } }),
|
2026-03-20 06:40:10 -07:00
|
|
|
).rejects.toThrow('Actions not supported in MockAgentProtocol: foo');
|
2026-03-16 10:59:02 -07:00
|
|
|
});
|
|
|
|
|
});
|