refactor agent pre-step inbox lifecycle
This commit is contained in:
@@ -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' } }))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user