refactor(agent): expose mutable inbox state

This commit is contained in:
_Kerman
2026-07-30 15:35:18 +08:00
parent c0ef93efc8
commit 4370004360
52 changed files with 534 additions and 913 deletions

View File

@@ -36,9 +36,6 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session }
session,
ctx: new Context(),
get status() { return status },
get acceptsNextStep() { return status === 'running' },
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
inject(input) { appendInjection(session, input) },

View File

@@ -324,37 +324,6 @@ export function apply(ctx: Context): void {
requestDrive(state)
}
})
ctx.on('agent/inbox/enqueue', (agent, item) => {
const state = stateFor(agent)
const attempt = state.attempt
if (attempt !== undefined && sameQueued(item.message.content, item.message.source, attempt)) return
state.competingQueued = true
if (attempt?.phase === 'queued') attempt.stale = true
})
ctx.on('agent/cancel-requested', (agent, cause) => {
const state = stateFor(agent)
const attempt = state.attempt
state.competingQueued = false
const goal = currentGoal(state)
if (goal?.phase === 'active' && goal.activation === 'armed') {
if (attempt === undefined) {
disarm(state)
return
}
// An admitted round closes durably as aborted; retain it so the normal
// turn outcome path appends pause after cancellation reaches idle.
// Pausing here would stage context into the active outbox only for this
// same cancel() call to discard it.
if (attempt.turn !== undefined || attempt.phase === 'admitted') return
state.attempt = undefined
try {
applyOutcome(state, goal, { kind: 'pause', reason: cause.kind })
} catch (error: unknown) {
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
disarm(state)
}
}
})
ctx.on('goal/changed', (agent) => {
const state = stateFor(agent)
state.needsCheckpoint = true
@@ -366,12 +335,14 @@ export function apply(ctx: Context): void {
if (agent === undefined || agent.session !== session) return
const state = stateFor(agent)
switch (event.type) {
case 'agent/inbox/added': {
case 'agent/inbox/spliced': {
if (event.data.target !== 'next-turn') return
const attempt = state.attempt
const { content, source } = event.data
if (attempt !== undefined && sameQueued(content, source, attempt)) return
state.competingQueued = true
if (attempt?.phase === 'queued') attempt.stale = true
for (const message of event.data.inserted) {
if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) continue
state.competingQueued = true
if (attempt?.phase === 'queued') attempt.stale = true
}
return
}
case 'turn/start': {

View File

@@ -9,7 +9,7 @@ import type { GoalView } from '@deepseek-ai/dsh-goal'
import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import type { TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
import * as goalSession from '../src/index.ts'
type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[])
@@ -101,6 +101,18 @@ async function harness(script: ScriptEntry[]): Promise<Harness> {
return { ctx, adapter, agent, driver }
}
/** Observe inserted inbox messages after the session append boundary closes. */
function onInboxMessage(
ctx: Context,
agent: Agent,
listener: (message: UserMessage) => void,
): () => void {
return ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'agent/inbox/spliced') return
for (const message of event.data.inserted) queueMicrotask(() => { listener(message) })
})
}
/** Await a stable goal projection selected by the caller. */
async function waitForGoal(
ctx: Context,
@@ -279,10 +291,10 @@ describe('same-session goal driving', () => {
it('pauses and drops a reserved round when cancellation lands before admission', async () => {
const test = await harness([])
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent === test.agent && info.message.source.kind === 'goal') {
const cancel = onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind === 'goal') {
cancel()
agent.cancel({ kind: 'user' })
test.agent.cancel({ kind: 'user' })
}
})
test.ctx.goals.create(test.agent, { objective: 'do not start yet' })
@@ -330,10 +342,10 @@ describe('same-session goal driving', () => {
it('makes a reserved round stale when a listener queues human work behind it', async () => {
const test = await harness([textResponse('human batch'), textResponse('later goal')])
let inserted = false
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.message.source.kind !== 'goal' || inserted) return
onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal' || inserted) return
inserted = true
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } }))
test.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } }))
})
test.ctx.goals.create(test.agent, { objective: 'yield to nested human input', maxGoalRounds: 1 })
@@ -348,12 +360,12 @@ describe('same-session goal driving', () => {
it('blocks a queued reservation made stale by a goal edit and continues the new revision', async () => {
const test = await harness([textResponse('new revision')])
let edited = false
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.message.source.kind !== 'goal' || edited) return
onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal' || edited) return
edited = true
const current = test.ctx.goals.get(agent)
const current = test.ctx.goals.get(test.agent)
if (current === undefined) throw new Error('missing goal during queued edit')
test.ctx.goals.edit(agent, current, { objective: 'new objective' })
test.ctx.goals.edit(test.agent, current, { objective: 'new objective' })
})
test.ctx.goals.create(test.agent, { objective: 'old objective', maxGoalRounds: 1 })
@@ -628,8 +640,8 @@ describe('same-session goal driving', () => {
it('fails a pre-admission read closed even when the first disarm attempt throws', async () => {
const test = await harness([textResponse('retry after containment')])
let armed = true
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.message.source.kind !== 'goal' || !armed) return
onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal' || !armed) return
armed = false
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
throw new Error('admission projection failed')
@@ -704,13 +716,13 @@ describe('same-session goal driving', () => {
it('falls back to disarming when a cancelled reservation cannot be paused', async () => {
const test = await harness([])
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent !== test.agent || info.message.source.kind !== 'goal') return
const cancel = onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind !== 'goal') return
cancel()
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
throw new Error('pause failed')
})
agent.cancel({ kind: 'user' })
test.agent.cancel({ kind: 'user' })
})
test.ctx.goals.create(test.agent, { objective: 'fail closed after cancellation' })
@@ -758,8 +770,8 @@ describe('same-session goal driving', () => {
it('cancels an accepted queued round and awaits its driver task during teardown', async () => {
const test = await harness([])
let unloading: Promise<void> | undefined
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
if (agent === test.agent && info.message.source.kind === 'goal' && unloading === undefined) {
onInboxMessage(test.ctx, test.agent, (message) => {
if (message.source.kind === 'goal' && unloading === undefined) {
unloading = Promise.resolve(test.driver.dispose())
}
})

View File

@@ -46,9 +46,6 @@ function stubAgentForSession(session: Session): StubAgent {
session,
ctx: new Context(),
get status() { return status },
get acceptsNextStep() { return status === 'running' },
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
inject(input) {

View File

@@ -37,9 +37,6 @@ function liveAgent(ctx: Context, session: Session): Agent {
session,
ctx,
get status() { return status },
get acceptsNextStep() { return false },
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
inject(input: UserMessage) {

View File

@@ -30,10 +30,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
options: {},
session,
get status() { return status },
get acceptsNextStep() { return status === 'running' },
ctx: new Context(),
send: () => {},
updateInbox: () => 'not-found',
followup: () => {},
steer: () => {},
inject(input) {