|
|
@@ -827,7 +827,104 @@ describe('agent loop', () => {
|
|
|
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
|
|
|
})
|
|
|
|
|
|
- it('chains queued messages into consecutive turns', async () => {
|
|
|
+ it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => {
|
|
|
+ const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
|
|
|
+ const ctx = await harness(adapter)
|
|
|
+ const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
+
|
|
|
+ const firstFlush = Promise.withResolvers<undefined>()
|
|
|
+ const releaseFirstFlush = Promise.withResolvers<undefined>()
|
|
|
+ let flushes = 0
|
|
|
+ ctx.on('session/flush', async (session) => {
|
|
|
+ if (session !== agent.session) return
|
|
|
+ flushes += 1
|
|
|
+ if (flushes === 1) {
|
|
|
+ firstFlush.resolve(undefined)
|
|
|
+ await releaseFirstFlush.promise
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const turns: number[] = []
|
|
|
+ ctx.on('session/event', (session, event) => {
|
|
|
+ if (session === agent.session && event.type === 'turn/start') turns.push(event.data.turn)
|
|
|
+ })
|
|
|
+
|
|
|
+ const idle = waitForIdle(ctx, agent)
|
|
|
+ send(agent, 'first message')
|
|
|
+ send(agent, 'second message')
|
|
|
+
|
|
|
+ await firstFlush.promise
|
|
|
+ expect(turns).toEqual([1])
|
|
|
+ expect(adapter.requests).toHaveLength(1)
|
|
|
+
|
|
|
+ releaseFirstFlush.resolve(undefined)
|
|
|
+ await idle
|
|
|
+
|
|
|
+ expect(turns).toEqual([1, 2])
|
|
|
+ expect(flushes).toBe(2)
|
|
|
+ expect(adapter.requests).toHaveLength(2)
|
|
|
+ expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
|
|
|
+ expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a reentrant agent/queued send as the next independent turn', async () => {
|
|
|
+ const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
|
|
+ const ctx = await harness(adapter)
|
|
|
+ const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
+
|
|
|
+ let nested = false
|
|
|
+ ctx.on('agent/queued', (subject) => {
|
|
|
+ if (subject !== agent || nested) return
|
|
|
+ nested = true
|
|
|
+ send(agent, 'queued listener message')
|
|
|
+ })
|
|
|
+
|
|
|
+ const idle = waitForIdle(ctx, agent)
|
|
|
+ send(agent, 'outer message')
|
|
|
+ await idle
|
|
|
+
|
|
|
+ const turns = agent.session.events.filter(event => event.type === 'turn/start')
|
|
|
+ const messages = agent.session.events
|
|
|
+ .filter(event => event.type === 'user/message')
|
|
|
+ .map(event => event.data.content)
|
|
|
+ expect(turns).toHaveLength(2)
|
|
|
+ expect(messages).toEqual([
|
|
|
+ [{ type: 'text', text: 'outer message' }],
|
|
|
+ [{ type: 'text', text: 'queued listener message' }],
|
|
|
+ ])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('preserves independent turn sources across an adjacent microtask send', async () => {
|
|
|
+ const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
|
|
+ const ctx = await harness(adapter)
|
|
|
+ const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
+
|
|
|
+ const idle = waitForIdle(ctx, agent)
|
|
|
+ agent.send([{ type: 'text', text: 'user message' }])
|
|
|
+ await Promise.resolve()
|
|
|
+ agent.send(
|
|
|
+ [{ type: 'text', text: 'plugin message' }],
|
|
|
+ { source: { kind: 'plugin', plugin: 'test' } },
|
|
|
+ )
|
|
|
+ await idle
|
|
|
+
|
|
|
+ const triggers = agent.session.events
|
|
|
+ .filter(event => event.type === 'turn/start')
|
|
|
+ .map(event => event.data.trigger)
|
|
|
+ const sources = agent.session.events
|
|
|
+ .filter(event => event.type === 'user/message')
|
|
|
+ .map(event => event.data.source)
|
|
|
+ expect(triggers).toEqual([
|
|
|
+ { kind: 'message', source: { kind: 'user' } },
|
|
|
+ { kind: 'message', source: { kind: 'plugin', plugin: 'test' } },
|
|
|
+ ])
|
|
|
+ expect(sources).toEqual([
|
|
|
+ { kind: 'user' },
|
|
|
+ { kind: 'plugin', plugin: 'test' },
|
|
|
+ ])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a session-listener send after dequeue in the following turn', async () => {
|
|
|
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
|
|
const ctx = await harness(adapter)
|
|
|
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
@@ -850,6 +947,37 @@ describe('agent loop', () => {
|
|
|
|
|
|
expect(turns).toEqual([1, 2])
|
|
|
expect(adapter.requests).toHaveLength(2)
|
|
|
+ expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first')
|
|
|
+ expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a model-adapter callback send in the following turn', async () => {
|
|
|
+ const agentRef: { current?: ReactLoopAgent } = {}
|
|
|
+ const adapter = new MockAdapter([
|
|
|
+ () => {
|
|
|
+ const agent = agentRef.current
|
|
|
+ if (agent === undefined) throw new Error('model callback ran before agent setup')
|
|
|
+ send(agent, 'model callback message')
|
|
|
+ return textResponse('first')
|
|
|
+ },
|
|
|
+ textResponse('second'),
|
|
|
+ ])
|
|
|
+ const ctx = await harness(adapter)
|
|
|
+ const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
|
|
+ agentRef.current = agent
|
|
|
+
|
|
|
+ const idle = waitForIdle(ctx, agent)
|
|
|
+ send(agent, 'outer message')
|
|
|
+ await idle
|
|
|
+
|
|
|
+ const messages = agent.session.events
|
|
|
+ .filter(event => event.type === 'user/message')
|
|
|
+ .map(event => event.data.content)
|
|
|
+ expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
|
|
+ expect(messages).toEqual([
|
|
|
+ [{ type: 'text', text: 'outer message' }],
|
|
|
+ [{ type: 'text', text: 'model callback message' }],
|
|
|
+ ])
|
|
|
})
|
|
|
|
|
|
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
|