refactor(agent): complete inbox lifecycle migration

This commit is contained in:
_Kerman
2026-08-03 12:25:33 +08:00
parent dc1d542092
commit 49e90695cc
214 changed files with 6019 additions and 4235 deletions

View File

@@ -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()

View File

@@ -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')

View File

@@ -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)

View File

@@ -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)

View 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()
})
})

View File

@@ -30,6 +30,7 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
return Object.assign(agent, overrides)
@@ -69,6 +70,25 @@ describe('Inbox', () => {
expect(inbox.nextTurn).toEqual([replacement])
})
it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => {
const session = new Session(SessionId('splice-inbox'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
const first = createUserMessage({
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
})
const second = createUserMessage({
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
})
inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
expect(inbox.nextTurn).toEqual([first, second])
expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second])
expect(inbox.remove('next-turn', second.id)).toBe(false)
expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
})
it('clears both pending lists as durable cancellations', () => {
const session = new Session(SessionId('clear-inbox'))
const discarded: UserMessage[] = []

View File

@@ -30,6 +30,17 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
export { foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Find the latest completed model turn in an event sequence.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest turn end, or `undefined`.
*/
export function findLastMessageTurnEnd(
events: readonly SessionEvent[],
): SessionEvent<'turn/end'> | undefined {
return events.findLast(event => event.type === 'turn/end')
}
declare module 'cordis' {
interface Context {
sessions: SessionStore

View File

@@ -188,6 +188,12 @@ describe('session-log invariants', () => {
skipped.append('step/end', { turn: 1, step: 1 })
expect(() => skipped.append('step/start', { turn: 1, step: 3 }))
.toThrow(/expected step 2 in turn 1, got 3/)
expect(() => skipped.append('turn/end', {
turn: 1,
step: 0,
reason: { kind: 'completed' },
})).toThrow(/expected last step 1, got 0/)
})
it('requires step-scoped stream and tool events to name the open step', async () => {