refactor(agent-loop): simplify message machine
This commit is contained in:
@@ -7,7 +7,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
* @module dsh-agent-loop/tests/cancel
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
@@ -55,33 +55,6 @@ function userTexts(agent: Agent): string[] {
|
||||
}
|
||||
|
||||
describe('Agent.cancel()', () => {
|
||||
it('notifies every observer before clearing work and contains listener failures', async () => {
|
||||
const adapter = new MockAdapter([textResponse('must remain unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' })
|
||||
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => {
|
||||
if (subject !== agent) return
|
||||
seen.push(`first:${cause.kind}`)
|
||||
subject.followup(createUserMessage({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } }))
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => {
|
||||
if (subject === agent) seen.push(`second:${cause.kind}`)
|
||||
})
|
||||
|
||||
send(agent, 'drop me')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
agent.cancel({ kind: 'parent' })
|
||||
|
||||
expect(seen).toEqual(['first:user', 'second:user'])
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested'))
|
||||
})
|
||||
|
||||
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -103,62 +76,29 @@ describe('Agent.cancel()', () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const discards: unknown[] = []
|
||||
ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
|
||||
const cancelRequests: unknown[] = []
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => { if (subject === agent) cancelRequests.push(cause) })
|
||||
const canceled: unknown[] = []
|
||||
ctx.on('agent/inbox/canceled', (subject, message) => { if (subject === agent) canceled.push(message) })
|
||||
|
||||
// Queue a turn WITHOUT waking the driver, so it sits in the inbox.
|
||||
agent.send(createUserMessage({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
|
||||
// keepInbox cancel: no active turn, work preserved, no discard event. With
|
||||
// nothing to abort and nothing discarded, the call is a documented no-op,
|
||||
// so it emits no cancel-requested either.
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'preserved' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
// Abort the collecting activity while preserving its queued item.
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
expect(discards).toEqual([])
|
||||
expect(cancelRequests).toEqual([])
|
||||
expect(canceled).toEqual([])
|
||||
|
||||
// The preserved item still runs once the driver is woken by a later send.
|
||||
// The preserved item still runs once a later follow-up wakes the driver.
|
||||
send(agent, 'wake it')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
|
||||
})
|
||||
|
||||
it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
|
||||
// resolves (the agent is quiescent), leaving the item queued.
|
||||
agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
|
||||
await agent.whenIdle()
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
|
||||
// A later waking send drives the loop, and the quiet item rides along first.
|
||||
send(agent, 'wake')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(userTexts(agent)).toEqual(['quiet', 'wake'])
|
||||
})
|
||||
|
||||
it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// send() queues synchronously (status still idle, loop microtask not yet
|
||||
// followup() queues synchronously (status still idle, loop microtask not yet
|
||||
// resumed). Cancel in that pre-step window: the queued turn must not run.
|
||||
send(agent, 'drop me first')
|
||||
send(agent, 'drop me second')
|
||||
|
||||
@@ -506,24 +506,6 @@ describe('driver bookkeeping edges', () => {
|
||||
expect(agent.session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('a whenIdle waiter survives a rejected driver promise', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('waiter-chain'), { provider: 'mock', model: 'mock' })
|
||||
// A throwing terminal-notification listener rejects the driver promise
|
||||
// (the run's containment covers only session appends); the waiter's
|
||||
// catch arm must treat that rejection as quiescence instead of
|
||||
// propagating it.
|
||||
ctx.on('agent/settled', (subject) => {
|
||||
if (subject === agent) throw new Error('settled listener exploded')
|
||||
})
|
||||
|
||||
send(agent, 'one')
|
||||
// Entered while the run owns the abort slot, the waiter awaits the
|
||||
// driver promise; its rejection must count as quiescence and resolve.
|
||||
await expect(agent.whenIdle()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('a request failure that concludes recovery after step/end closed keeps the boundary balanced', async () => {
|
||||
const { LlmError } = await import('@deepseek-ai/dsh-llm')
|
||||
// The failure finish-chunk path returns request-failed AFTER step() has
|
||||
|
||||
@@ -25,7 +25,7 @@ function loopRequest<T extends object>(options: T): Readonly<T> {
|
||||
async function requestSetup() {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-check'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -74,7 +74,7 @@ describe('request-reconstruction invariant', () => {
|
||||
it('rejects loop requests with no boundary or header', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-bare'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
@@ -122,7 +122,7 @@ describe('request-reconstruction invariant', () => {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
const session = ctx.sessions.create(SessionId('prepend-check'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
@@ -433,8 +433,6 @@ describe('agent loop', () => {
|
||||
// split the assistant tool call from the provider's tool-result message.
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(turnStarts).toHaveLength(1)
|
||||
const ts0 = turnStarts[0]!
|
||||
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
|
||||
const result = agent.session.events.find(e => e.type === 'tool/result')!
|
||||
const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(contexts).toHaveLength(2)
|
||||
@@ -1031,16 +1029,11 @@ describe('agent loop', () => {
|
||||
agent.followup(createUserMessage({ content: [{ 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 turns = agent.session.events.filter(event => event.type === 'turn/start')
|
||||
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(turns).toHaveLength(2)
|
||||
expect(sources).toEqual([
|
||||
{ kind: 'user' },
|
||||
{ kind: 'plugin', plugin: 'test' },
|
||||
|
||||
@@ -63,13 +63,9 @@ describe('agent/request-error', () => {
|
||||
retryPolicy: ResolvedRetryPolicy | undefined
|
||||
}[] = []
|
||||
const statuses: string[] = []
|
||||
const settledTurns: number[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) statuses.push(status)
|
||||
})
|
||||
ctx.on('agent/settled', (subject, turn) => {
|
||||
if (subject === agent) settledTurns.push(turn)
|
||||
})
|
||||
ctx.on('agent/request-error', async (
|
||||
subject, turn, step, _error, failure, priorFailures, retryPolicy,
|
||||
) => {
|
||||
@@ -101,12 +97,7 @@ describe('agent/request-error', () => {
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
},
|
||||
])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start').map(event => event.data.trigger))
|
||||
.toEqual([
|
||||
{ kind: 'message', source: { kind: 'user' } },
|
||||
{ kind: 'retry' },
|
||||
{ kind: 'retry' },
|
||||
])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(seen.map(item => item.priorFailures.map(failure => failure.code)))
|
||||
.toEqual([[], ['RATE_LIMIT']])
|
||||
expect(seen.map(item => item.retryPolicy)).toEqual([
|
||||
@@ -114,7 +105,6 @@ describe('agent/request-error', () => {
|
||||
expect.objectContaining({ mode: 'normal' }),
|
||||
])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(settledTurns).toEqual([3])
|
||||
})
|
||||
|
||||
it('lets cancellation win over a retry action', async () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
|
||||
// balanced completed turn is the smallest resumable log and avoids running
|
||||
// the model merely to construct this lifecycle fixture.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const session = ctx.sessions.create(sessionId, { seed })
|
||||
@@ -86,7 +86,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
createdAt: 1,
|
||||
})
|
||||
await first.ctx.sessionPersistence.append(sessionId, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
@@ -175,7 +175,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')]))
|
||||
const sessionId = SessionId('live-resume-race')
|
||||
const first = (await ctx.agents.create({ sessionId })).agent
|
||||
first.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
first.session.append('turn/start', { turn: 1 })
|
||||
await ctx.sessions.flush(first.session)
|
||||
|
||||
await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
|
||||
@@ -494,7 +494,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
// materializes the fork (header + seed) on disk.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('agent scope lifecycle', () => {
|
||||
expect(agent.ctx.agent).toBe(agent)
|
||||
// The root accessor default: a plain context answers undefined, not a throw.
|
||||
expect(ctx.agent).toBeUndefined()
|
||||
await ctx.agents.get(SessionId('a1'))?.whenIdle()
|
||||
await agent.whenIdle()
|
||||
})
|
||||
|
||||
it('records agents created through an agent context as non-root runtime children', async () => {
|
||||
|
||||
Reference in New Issue
Block a user