refactor(agent): complete inbox lifecycle migration
This commit is contained in:
@@ -190,6 +190,7 @@ export class ReactLoopAgent implements Agent {
|
||||
} catch (_error) {
|
||||
// Reported failures and cancellation are contained at the driver boundary.
|
||||
} finally {
|
||||
/* v8 ignore next -- kick owns a running phase until this driver boundary */
|
||||
if (this.phase.kind === 'running') {
|
||||
this.setPhase({ kind: 'idle', lastTurn: this.phase.turn })
|
||||
}
|
||||
@@ -197,6 +198,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {
|
||||
/* v8 ignore next -- private callers establish the running phase before proposing a step */
|
||||
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
|
||||
const signal = this.phase.abort.signal
|
||||
const claimed = this.inbox.claim(target)
|
||||
@@ -294,6 +296,7 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
private async step(assembly: PromptAssembly): Promise<StepEndReason | null> {
|
||||
/* v8 ignore next -- private callers establish the running phase before executing a step */
|
||||
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": step outside running phase`)
|
||||
const { turn, step, abort: { signal } } = this.phase
|
||||
signal.throwIfAborted()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, freezeMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { createUserMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
@@ -59,152 +59,6 @@ function inboxText(message: UserMessage): string {
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('addressable inbox operations', () => {
|
||||
it('edits in place and removes exactly one queued item', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('first reply'),
|
||||
textResponse('edited reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' })
|
||||
const preStep = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('agent/pre-step', async (_subject, messages, _signal, next) => {
|
||||
if (messages[0]?.content[0]?.type === 'text' && messages[0].content[0].text === 'first') {
|
||||
preStep.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await preStep.promise
|
||||
send(agent, 'remove me')
|
||||
send(agent, 'edit me')
|
||||
const pending = agent.inbox.nextTurn
|
||||
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
|
||||
|
||||
const remove = pending[0]!
|
||||
const edit = pending[1]!
|
||||
expect(agent.inbox.splice('next-turn', 1, 1, [freezeMessage({
|
||||
...edit,
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})])).toEqual([edit])
|
||||
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([remove])
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
release.resolve(undefined)
|
||||
await idle
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.type === 'user/message'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
: ''))
|
||||
.toEqual(['first', 'edited'])
|
||||
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([])
|
||||
})
|
||||
|
||||
it('strictly transfers a queued occurrence into the open turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('queue-to-steer'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent) enqueued.push(item)
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discarded.push(...items)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'open the turn')
|
||||
const receipt = agent.steer(createUserMessage({
|
||||
content: [{ type: 'text', text: 'steer this message' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await entered.promise
|
||||
const queued = enqueued.find(item => inboxText(item) === 'steer this message')!
|
||||
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
|
||||
const steering = enqueued.find(item => item.placement === 'steering')!
|
||||
expect(steering.id).not.toBe(queued.id)
|
||||
expect(steering.message).toBe(queued.message)
|
||||
expect(discarded).toEqual([queued])
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.session.events.flatMap(event =>
|
||||
event.type === 'steering/message' ? [event.data.message] : [],
|
||||
)).toEqual([queued.message])
|
||||
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 1, step: 1 })
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found')
|
||||
})
|
||||
|
||||
it('keeps a queued occurrence when the next-step window is closed', () => {
|
||||
const ctx = new Context()
|
||||
const session = new Session(SessionId('queue-to-steer-closed'))
|
||||
const agent = new ReactLoopAgent(ctx, session.id, {}, session)
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (_subject, item) => { enqueued.push(item) })
|
||||
ctx.on('agent/inbox/discard', (_subject, items) => { discarded.push(...items) })
|
||||
|
||||
agent.send(
|
||||
createUserMessage({ content: [{ type: 'text', text: 'stay queued' }], source: { kind: 'user' } }),
|
||||
{ target: 'next-turn', wakeup: false },
|
||||
)
|
||||
const queued = enqueued[0]!
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('steer-unavailable')
|
||||
expect(discarded).toEqual([])
|
||||
expect(agent.updateInbox(queued.id, { kind: 'remove' })).toBe('applied')
|
||||
})
|
||||
|
||||
it('accounts for both occurrences when steering enqueue cancels reentrantly', async () => {
|
||||
const adapter = new MockAdapter([textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('queue-to-steer-cancel'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent) return
|
||||
enqueued.push(item)
|
||||
if (item.placement === 'steering') agent.cancel({ kind: 'user' })
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discarded.push(...items)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'open the turn')
|
||||
await entered.promise
|
||||
send(agent, 'cancel during conversion')
|
||||
const queued = enqueued.find(item => inboxText(item) === 'cancel during conversion')!
|
||||
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
|
||||
const steering = enqueued.find(item => item.placement === 'steering')!
|
||||
expect(discarded).toEqual([steering, queued])
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant replay provenance', () => {
|
||||
it('records adapter replay state with the assembled assistant content', async () => {
|
||||
const response = textResponse('unchanged')
|
||||
|
||||
@@ -204,6 +204,33 @@ describe('agent/pre-step', () => {
|
||||
expect(sent).toContain('extra ctx')
|
||||
})
|
||||
|
||||
it('does not open another step when a completed turn rewrites pending input to empty', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
ctx.on('agent/turn-stopping', (subject) => {
|
||||
subject.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'pending context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
})
|
||||
ctx.on('agent/pre-step', async (_subject, _messages, context, next) => {
|
||||
const decision = await next()
|
||||
return context.step === 1 || decision.kind === 'reject'
|
||||
? decision
|
||||
: { kind: 'enter', messages: [] }
|
||||
})
|
||||
|
||||
send(agent, 'finish once')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reject drops the claimed prompt before any turn or model call', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -73,6 +73,35 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests[0]?.maxTokens).toBe(256)
|
||||
})
|
||||
|
||||
it('cancels queued wakeup work together with an active maintenance task', async () => {
|
||||
const adapter = new MockAdapter([textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const maintenance = agent.runMaintenance(async (signal) => {
|
||||
started.resolve(undefined)
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new Error('maintenance aborted', { cause: signal.reason }))
|
||||
}, { once: true })
|
||||
})
|
||||
})
|
||||
await started.promise
|
||||
|
||||
send(agent, 'discard this wakeup')
|
||||
agent.cancel({ kind: 'user' })
|
||||
send(agent, 'park after cancellation')
|
||||
|
||||
await expect(maintenance).rejects.toThrow('maintenance aborted')
|
||||
await agent.whenIdle()
|
||||
expect(agent.inbox.nextTurn).toHaveLength(1)
|
||||
expect(adapter.requests).toEqual([])
|
||||
agent.cancel({ kind: 'user' })
|
||||
})
|
||||
|
||||
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hello there')])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
39
packages/core/agent-loop/tests/runtime-context.spec.ts
Normal file
39
packages/core/agent-loop/tests/runtime-context.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { RuntimeContextProjection } from '../src/runtime-context.ts'
|
||||
|
||||
const SOURCE = '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
function contextMessage(text: string) {
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: SOURCE },
|
||||
})
|
||||
}
|
||||
|
||||
describe('RuntimeContextProjection', () => {
|
||||
it('restores the latest visible owned snapshot and ignores other sessions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('runtime-context-replay'))
|
||||
const retained = session.append('user/message', contextMessage('retained'), { surfaceOp: 'append' })
|
||||
const shadowed = session.append('user/message', contextMessage('shadowed'), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compaction' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: shadowed.seq, end: shadowed.seq },
|
||||
sourceEventSeqs: [shadowed.seq],
|
||||
})
|
||||
|
||||
const projection = new RuntimeContextProjection(ctx, session)
|
||||
expect(session.surface.nodes).toContain(retained.seq)
|
||||
expect(projection.project('retained')).toBeUndefined()
|
||||
|
||||
const other = ctx.sessions.create(SessionId('runtime-context-other'))
|
||||
other.append('user/message', contextMessage('other'), { surfaceOp: 'append' })
|
||||
expect(projection.project('retained')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user