fix(plan-mode): flush pending mode only at the in-turn agent/step seam

Prompt admission runs before any turn opens, so the agent/prompt-submit
flush listener could only ever append plan/mode outside an open turn —
invariant-vetoed and retried at agent/step when the session invariant is
mounted, silently out-of-turn in the durable log when it is not. agent/step
fires inside the open turn before every request derivation (including
turn 1 step 1), so it is the sole flush point and no behavior is lost: a
pending idle switch lands as the first step's in-turn plan/mode. Tests pin
the new contract: admission never appends; the first step boundary flushes.
This commit is contained in:
_Kerman
2026-07-26 11:47:39 +08:00
parent 2154034a0b
commit 4284530c7e
2 changed files with 44 additions and 53 deletions

View File

@@ -157,24 +157,13 @@ export class PlanModeService extends Service {
this.section = resolveConfig(config).section this.section = resolveConfig(config).section
let disposed = false let disposed = false
// Boundary flushes use loop interception seams, not post-commit // The boundary flush uses the loop's `agent/step` interception seam, not
// `session/event` observation. Flush after next(): a selection arriving // post-commit `session/event` observation. `agent/step` runs inside the
// while a downstream async listener awaits must still shape the request // open turn before every request derivation (including turn 1 step 1), so
// this boundary precedes. Failures are contained so policy cannot block a // it is the sole flush point: prompt admission happens pre-turn, where a
// prompt or turn; a failed append remains pending for a later boundary. // `plan/mode` append would land outside any open turn. Failures are
const flushAfter = async <T>(agent: Agent, next: () => Promise<T>): Promise<T> => { // contained so policy cannot block a turn; a failed append remains
const decision = await next() // pending for a later boundary.
if (!disposed) {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
}
return decision
}
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/step', (agent) => { ctx.on('agent/step', (agent) => {
if (disposed) return if (disposed) return
try { try {

View File

@@ -177,31 +177,17 @@ describe('ctx.planMode: get/set', () => {
}) })
describe('the boundary flush', () => { describe('the boundary flush', () => {
it('flushes the pending intent as a plan/mode at turn/start', async () => { it('does not flush at prompt admission — the seam is pre-turn, so the first step boundary lands it', async () => {
const ctx = await setup() const ctx = await setup()
const agent = await agentWithSession(ctx) const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true) 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') await boundary(ctx, agent, 'turn/start')
expect(foldPlanMode(agent.session.events)).toBe(true) expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
expect(ctx.planMode.get(agent)).toEqual({ active: true }) expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
}) await boundary(ctx, agent, 'step/end')
it('flushes a set() that arrives while a downstream listener is still awaiting (post-next ordering)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
// A downstream async listener (the shipped hooks listeners' shape): the
// selection lands DURING its await — after this boundary began, before it
// returns. The prepended flush runs after next(), so the plan/mode still
// precedes the request this boundary gates.
ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next) => {
await new Promise(resolve => setTimeout(resolve, 5))
ctx.planMode.set(agent, true)
return next()
})
await agentEvents(ctx, agent).waterfall(
'agent/prompt-submit', [{ type: 'text', text: 'probe' }], { kind: 'user' },
new AbortController().signal, () => Promise.resolve({ kind: 'allow' }),
)
expect(foldPlanMode(agent.session.events)).toBe(true) expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true }) expect(ctx.planMode.get(agent)).toEqual({ active: true })
}) })
@@ -213,17 +199,31 @@ describe('the boundary flush', () => {
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG) const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx) const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, true) ctx.planMode.set(agent, true)
// A downstream listener captured before disposal keeps the waterfall // A listener captured in the same dispatch snapshot keeps the plan-mode
// continuation alive across the unload; the resumed wrapper must not // callback alive across the unload; the resumed wrapper must not append
// append through the disposed service. // through the disposed service. Registered prepended AFTER the plugin so
ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next) => { // it runs before plan-mode's own prepended flush.
ctx.on('agent/step', async () => {
await fiber.dispose() await fiber.dispose()
return next() }, { prepend: true })
}) await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
await agentEvents(ctx, agent).waterfall( expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
'agent/prompt-submit', [{ type: 'text', text: 'probe' }], { kind: 'user' }, })
new AbortController().signal, () => Promise.resolve({ kind: 'allow' }),
) 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)
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)
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
}) })
@@ -259,7 +259,7 @@ describe('the boundary flush', () => {
const agent = await agentWithSession(ctx) const agent = await agentWithSession(ctx)
header(agent.session) header(agent.session)
ctx.planMode.set(agent, true) ctx.planMode.set(agent, true)
await boundary(ctx, agent, 'turn/start') await boundary(ctx, agent, 'step/end')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.']) expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
await boundary(ctx, agent, 'step/end') await boundary(ctx, agent, 'step/end')
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.']) expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
@@ -313,7 +313,7 @@ describe('the boundary flush', () => {
expect(ctx.planMode.get(agent).pending).toBeUndefined() expect(ctx.planMode.get(agent).pending).toBeUndefined()
}) })
it('contains an append failure on the prompt-submit seam the same way', async () => { it('prompt admission never appends, so a broken backend surfaces only at the step boundary', async () => {
const ctx = await setup() const ctx = await setup()
const warn = vi.fn() const warn = vi.fn()
ctx.logger.warn = warn as never ctx.logger.warn = warn as never
@@ -325,6 +325,8 @@ describe('the boundary flush', () => {
return (original as (...args: unknown[]) => unknown)(type, ...rest) return (original as (...args: unknown[]) => unknown)(type, ...rest)
}) as unknown) as typeof agent.session.append }) as unknown) as typeof agent.session.append
await boundary(ctx, agent, 'turn/start') await boundary(ctx, agent, 'turn/start')
expect(warn).not.toHaveBeenCalled()
await boundary(ctx, agent, 'step/end')
expect(warn).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledOnce()
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
}) })
@@ -541,7 +543,7 @@ describe('/plan', () => {
.toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' }) .toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' })
expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false }) expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false })
expect(enteringSteer).not.toHaveBeenCalled() expect(enteringSteer).not.toHaveBeenCalled()
await boundary(ctx, entering, 'turn/start') await boundary(ctx, entering, 'step/end')
expect(ctx.planMode.get(entering)).toEqual({ active: false }) expect(ctx.planMode.get(entering)).toEqual({ active: false })
expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false) expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)
@@ -554,7 +556,7 @@ describe('/plan', () => {
expect(await ctx.commands.execute(active, '/plan off', signal)) expect(await ctx.commands.execute(active, '/plan off', signal))
.toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' }) .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' })
expect(activeSteer).not.toHaveBeenCalled() expect(activeSteer).not.toHaveBeenCalled()
await boundary(ctx, active, 'turn/start') await boundary(ctx, active, 'step/end')
expect(ctx.planMode.get(active)).toEqual({ active: false }) expect(ctx.planMode.get(active)).toEqual({ active: false })
}) })