refactor agent pre-step inbox lifecycle

This commit is contained in:
_Kerman
2026-07-31 19:21:16 +08:00
parent c2ff9ddec8
commit fcc2b5e282
267 changed files with 2052 additions and 1546 deletions

View File

@@ -71,8 +71,7 @@ describe('plan mode through the agent loop', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
// Selected while idle: the pending intent flushes at the first
// in-turn agent/step seam, before the first assembly.
// Selected while idle: the mode commits immediately, before the first assembly.
ctx.planMode.set(agent, true)
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } }))

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, {
@@ -21,15 +21,22 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
* Drives the REAL plugin: mounts `dsh-plan-mode` beside real `SystemPrompt` and
* `ToolRegistry` services, with fake Agents carrying real `Session`s and a
* real scoped `agent.ctx` minted through `createScope`.
* Request boundaries are simulated by dispatching the real prompt-admission
* and between-step seams used by the loop.
* Request boundaries are simulated by dispatching the real pre-step waterfall
* and the following `step/start` session event used by the loop.
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
// A live store session when a store is mounted (the command executor logs
// lifecycle events through it); bare otherwise (fold/tool-only benches).
const session = new Session(SessionId(id))
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
const agent = {
id: SessionId(id),
session,
options: {},
inject(message: UserMessage) {
session.append('user/message', message, { surfaceOp: 'append' })
},
} as unknown as Agent & { session: Session }
let scoped!: Context
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
inject: ['tools'],
@@ -56,24 +63,30 @@ async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise<Context> {
}
/**
* Dispatch either prompt admission or the between-step checkpoint.
* Dispatch pre-step processing and optionally its following step-start commit.
*/
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'turn/start' | 'step/end'): Promise<void> {
async function boundary(ctx: Context, agent: Agent & { session: Session }, type: 'pre-step' | 'step-start'): Promise<void> {
const events = agentEvents(ctx, agent)
if (type === 'turn/start') {
const message = createUserMessage({
content: [{ type: 'text', text: 'boundary probe' }],
source: { kind: 'user' },
})
await events.waterfall(
'agent/prompt-submit',
[message],
new AbortController().signal,
() => Promise.resolve({ kind: 'allow', messages: [message] }),
)
return
const message = createUserMessage({
content: [{ type: 'text', text: 'boundary probe' }],
source: { kind: 'user' },
})
const signal = new AbortController().signal
const decision = await events.waterfall(
'agent/pre-step',
[message],
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [message] }),
)
if (decision.kind === 'enter') {
for (const message of decision.messages.slice(1)) {
agent.session.append('user/message', message, { surfaceOp: 'append' })
}
}
if (type === 'step-start') {
const event = agent.session.append('step/start', { turn: 1, step: 1 })
ctx.emit('session/event', agent.session, event)
}
await events.serial('agent/step', 1, 2, new AbortController().signal)
}
/** Open a turn so a selection queues for the boundary flush (the mid-turn shape). */
@@ -210,7 +223,7 @@ describe('ctx.planMode: get/set', () => {
expect(ctx.planMode.set(agent, false)).toBe('committed')
expect(foldPlanMode(agent.session.events)).toBe(false)
// A later boundary finds nothing pending — no double append.
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2)
})
@@ -236,23 +249,22 @@ describe('ctx.planMode: get/set', () => {
})
describe('the boundary flush', () => {
it('does not flush at prompt admission — the seam is pre-turn, so the first step boundary lands it', async () => {
it('does not flush during pre-step and commits from the following step/start', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
// Prompt admission runs before any turn opens; a plan/mode appended there
// would sit outside the turn. The pending intent survives admission and
// the in-turn agent/step boundary flushes it before the request derives.
await boundary(ctx, agent, 'turn/start')
// Pre-step only composes narration. The pending intent survives until the
// turn-enclosed step/start event commits it before request assembly.
await boundary(ctx, agent, 'pre-step')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('skips the flush after the plugin fiber is disposed (a captured wrapper must not write into a dead service)', async () => {
it('removes the step/start flush when the plugin fiber is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -260,32 +272,9 @@ describe('the boundary flush', () => {
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
// A listener captured in the same dispatch snapshot keeps the plan-mode
// callback alive across the unload; the resumed wrapper must not append
// through the disposed service. Registered prepended AFTER the plugin so
// it runs before plan-mode's own prepended flush.
ctx.on('agent/step', async () => {
await fiber.dispose()
}, { prepend: true })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
it('skips the step-seam flush after the plugin fiber is disposed (a captured listener must not write into a dead service)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
// Serial dispatch captures its listener list up front; prepending after
// the plugin puts this listener ahead of the plugin's own prepended one,
// so the plugin's captured callback still runs after the disposal below.
ctx.on('agent/step', async () => {
await fiber.dispose()
}, { prepend: true })
await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
await fiber.dispose()
const event = agent.session.append('step/start', { turn: 1, step: 1 })
ctx.emit('session/event', agent.session, event)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
@@ -293,7 +282,7 @@ describe('the boundary flush', () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(true)
})
@@ -304,7 +293,7 @@ describe('the boundary flush', () => {
openTurn(agent.session)
ctx.planMode.set(agent, true)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'turn/start')
await boundary(ctx, agent, 'pre-step')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -313,7 +302,7 @@ describe('the boundary flush', () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start')
await boundary(ctx, agent, 'pre-step')
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -322,9 +311,9 @@ describe('the boundary flush', () => {
const agent = await agentWithSession(ctx)
header(agent.session)
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
})
@@ -334,7 +323,7 @@ describe('the boundary flush', () => {
agent.session.append('plan/mode', { active: true })
header(agent.session)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session back to the default mode.'])
})
@@ -345,7 +334,7 @@ describe('the boundary flush', () => {
header(agent.session)
agent.session.append('plan/mode', { active: false })
ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -365,19 +354,19 @@ describe('the boundary flush', () => {
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(warn).toHaveBeenCalledOnce()
// The failed flush re-parks the intent (cleared only after a landed
// append), so the next healthy boundary converges the log with the
// picker's optimistic state instead of dropping the switch forever.
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
agent.session.append = original
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent).pending).toBeUndefined()
})
it('prompt admission never appends, so a broken backend surfaces only at the step boundary', async () => {
it('pre-step never appends, so a broken backend surfaces only at step/start', async () => {
const ctx = await setup()
const warn = vi.fn()
ctx.logger.warn = warn as never
@@ -389,9 +378,9 @@ describe('the boundary flush', () => {
if (type === 'plan/mode') throw new Error('backend gone')
return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'turn/start')
await boundary(ctx, agent, 'pre-step')
expect(warn).not.toHaveBeenCalled()
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(warn).toHaveBeenCalledOnce()
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
@@ -752,7 +741,7 @@ describe('exit_plan_mode', () => {
// step's end, so the plan policy covers any remaining call of the SAME batch.
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(false)
expect(asked).toHaveLength(1)
expect(asked[0]?.agent).toBe(agent)
@@ -820,7 +809,7 @@ describe('exit_plan_mode', () => {
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe(TEST_PLAN_SECTION)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(false)
const afterExit = await ctx.systemPrompt.assemble({ agent })
expect(afterExit.tools).toEqual(assembly.tools)
@@ -831,7 +820,7 @@ describe('exit_plan_mode', () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
header(agent.session)
await callExit(ctx, agent)
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(foldPlanMode(agent.session.events)).toBe(false)
expect(noticeTexts(agent.session)).toEqual([])
})
@@ -1024,7 +1013,7 @@ describe('HMR disposal', () => {
expect(ctx.get('planMode')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy')
await boundary(ctx, agent, 'step/end')
await boundary(ctx, agent, 'step-start')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
})
})