Skip to content

Commit efeb9f7

Browse files
authored
fix(core): harden AgentSession replay semantics (#23548)
1 parent 5a65610 commit efeb9f7

5 files changed

Lines changed: 328 additions & 99 deletions

File tree

packages/core/src/agent/agent-session.test.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ describe('AgentSession', () => {
117117
expect(events).toHaveLength(0);
118118
expect(protocol.events).toHaveLength(1);
119119
expect(protocol.events[0].type).toBe('session_update');
120+
expect(protocol.events[0].streamId).toEqual(expect.any(String));
120121
});
121122

122123
it('should skip events that occur before agent_start', async () => {
@@ -171,6 +172,181 @@ describe('AgentSession', () => {
171172
expect(streamedEvents).toEqual(allEvents.slice(2));
172173
});
173174

175+
it('should complete immediately when resuming from agent_end', async () => {
176+
const protocol = new MockAgentProtocol();
177+
const session = new AgentSession(protocol);
178+
179+
protocol.pushResponse([{ type: 'message' }]);
180+
const { streamId } = await session.send({
181+
message: [{ type: 'text', text: 'request' }],
182+
});
183+
await new Promise((resolve) => setTimeout(resolve, 10));
184+
185+
const endEvent = session.events.findLast(
186+
(event): event is AgentEvent<'agent_end'> =>
187+
event.type === 'agent_end' && event.streamId === streamId,
188+
);
189+
expect(endEvent).toBeDefined();
190+
191+
const iterator = session
192+
.stream({ eventId: endEvent!.id })
193+
[Symbol.asyncIterator]();
194+
await expect(iterator.next()).resolves.toEqual({
195+
value: undefined,
196+
done: true,
197+
});
198+
});
199+
200+
it('should throw for an unknown eventId', async () => {
201+
const protocol = new MockAgentProtocol();
202+
const session = new AgentSession(protocol);
203+
204+
const iterator = session
205+
.stream({ eventId: 'missing-event' })
206+
[Symbol.asyncIterator]();
207+
await expect(iterator.next()).rejects.toThrow(
208+
'Unknown eventId: missing-event',
209+
);
210+
});
211+
212+
it('should throw when resuming from an event before agent_start on a stream with no agent activity', async () => {
213+
const protocol = new MockAgentProtocol();
214+
const session = new AgentSession(protocol);
215+
216+
const { streamId } = await session.send({ update: { title: 'draft' } });
217+
expect(streamId).toBeNull();
218+
219+
const updateEvent = session.events.find(
220+
(event): event is AgentEvent<'session_update'> =>
221+
event.type === 'session_update',
222+
);
223+
expect(updateEvent).toBeDefined();
224+
225+
const iterator = session
226+
.stream({ eventId: updateEvent!.id })
227+
[Symbol.asyncIterator]();
228+
await expect(iterator.next()).rejects.toThrow(
229+
`Cannot resume from eventId ${updateEvent!.id} before agent_start for stream ${updateEvent!.streamId}`,
230+
);
231+
});
232+
233+
it('should replay from agent_start when resuming from a pre-agent_start event after activity is in history', async () => {
234+
const protocol = new MockAgentProtocol();
235+
const session = new AgentSession(protocol);
236+
237+
protocol.pushResponse([
238+
{
239+
type: 'message',
240+
role: 'agent',
241+
content: [{ type: 'text', text: 'hello' }],
242+
},
243+
]);
244+
await session.send({
245+
message: [{ type: 'text', text: 'request' }],
246+
});
247+
await new Promise((resolve) => setTimeout(resolve, 10));
248+
249+
const userMessage = session.events.find(
250+
(event): event is AgentEvent<'message'> =>
251+
event.type === 'message' && event.role === 'user',
252+
);
253+
expect(userMessage).toBeDefined();
254+
255+
const streamedEvents: AgentEvent[] = [];
256+
for await (const event of session.stream({ eventId: userMessage!.id })) {
257+
streamedEvents.push(event);
258+
}
259+
260+
expect(streamedEvents.map((event) => event.type)).toEqual([
261+
'agent_start',
262+
'message',
263+
'agent_end',
264+
]);
265+
expect(streamedEvents[0]?.streamId).toBe(userMessage!.streamId);
266+
});
267+
268+
it('should throw when resuming from a pre-agent_start event before activity is in history', async () => {
269+
const protocol = new MockAgentProtocol([
270+
{
271+
id: 'e-1',
272+
timestamp: '2026-01-01T00:00:00.000Z',
273+
streamId: 'stream-1',
274+
type: 'message',
275+
role: 'user',
276+
content: [{ type: 'text', text: 'request' }],
277+
},
278+
]);
279+
const session = new AgentSession(protocol);
280+
281+
const iterator = session
282+
.stream({ eventId: 'e-1' })
283+
[Symbol.asyncIterator]();
284+
await expect(iterator.next()).rejects.toThrow(
285+
'Cannot resume from eventId e-1 before agent_start for stream stream-1',
286+
);
287+
});
288+
289+
it('should resume from an in-stream event within the same stream only', async () => {
290+
const protocol = new MockAgentProtocol();
291+
const session = new AgentSession(protocol);
292+
293+
protocol.pushResponse([
294+
{
295+
type: 'message',
296+
role: 'agent',
297+
content: [{ type: 'text', text: 'first answer 1' }],
298+
},
299+
{
300+
type: 'message',
301+
role: 'agent',
302+
content: [{ type: 'text', text: 'first answer 2' }],
303+
},
304+
]);
305+
const { streamId: streamId1 } = await session.send({
306+
message: [{ type: 'text', text: 'first request' }],
307+
});
308+
await new Promise((resolve) => setTimeout(resolve, 10));
309+
310+
protocol.pushResponse([
311+
{
312+
type: 'message',
313+
role: 'agent',
314+
content: [{ type: 'text', text: 'second answer' }],
315+
},
316+
]);
317+
await session.send({
318+
message: [{ type: 'text', text: 'second request' }],
319+
});
320+
await new Promise((resolve) => setTimeout(resolve, 10));
321+
322+
const resumeEvent = session.events.find(
323+
(event): event is AgentEvent<'message'> =>
324+
event.type === 'message' &&
325+
event.streamId === streamId1 &&
326+
event.role === 'agent' &&
327+
event.content[0]?.type === 'text' &&
328+
event.content[0].text === 'first answer 1',
329+
);
330+
expect(resumeEvent).toBeDefined();
331+
332+
const streamedEvents: AgentEvent[] = [];
333+
for await (const event of session.stream({ eventId: resumeEvent!.id })) {
334+
streamedEvents.push(event);
335+
}
336+
337+
expect(
338+
streamedEvents.every((event) => event.streamId === streamId1),
339+
).toBe(true);
340+
expect(streamedEvents.map((event) => event.type)).toEqual([
341+
'message',
342+
'agent_end',
343+
]);
344+
const resumedMessage = streamedEvents[0] as AgentEvent<'message'>;
345+
expect(resumedMessage.content).toEqual([
346+
{ type: 'text', text: 'first answer 2' },
347+
]);
348+
});
349+
174350
it('should replay events for streamId starting with agent_start', async () => {
175351
const protocol = new MockAgentProtocol();
176352
const session = new AgentSession(protocol);
@@ -223,6 +399,33 @@ describe('AgentSession', () => {
223399
expect(streamedEvents.at(-1)?.type).toBe('agent_end');
224400
});
225401

402+
it('should not drop agent_end that arrives while replay events are being yielded', async () => {
403+
const protocol = new MockAgentProtocol();
404+
const session = new AgentSession(protocol);
405+
406+
protocol.pushResponse([{ type: 'message' }], { keepOpen: true });
407+
const { streamId } = await session.send({ update: { title: 't1' } });
408+
await new Promise((resolve) => setTimeout(resolve, 10));
409+
410+
const iterator = session
411+
.stream({ streamId: streamId! })
412+
[Symbol.asyncIterator]();
413+
414+
const first = await iterator.next();
415+
expect(first.value?.type).toBe('agent_start');
416+
417+
protocol.pushToStream(streamId!, [], { close: true });
418+
419+
const second = await iterator.next();
420+
expect(second.value?.type).toBe('message');
421+
422+
const third = await iterator.next();
423+
expect(third.value?.type).toBe('agent_end');
424+
425+
const fourth = await iterator.next();
426+
expect(fourth.done).toBe(true);
427+
});
428+
226429
it('should follow an active stream if no options provided', async () => {
227430
const protocol = new MockAgentProtocol();
228431
const session = new AgentSession(protocol);

0 commit comments

Comments
 (0)