fix(plan): gate the immediate commit on the open-turn fold, not agent status

The TUI keyless smoke exposed the wrong idle signal: /plan submitted while
the driver is between turns still saw status running (checkpointing), so
the selection queued and the immediate copy never appeared. The commit
gate is now the log's own open-turn fold — plan/mode commits at once
whenever no turn is open, and queues only inside one. Specs drive the
distinction through explicit turn/start / turn/end events instead of a
fake status; the keyless smoke pins the new idle copy on both /plan and
/plan off; regenerated cordis/service catalogs ride along.
This commit is contained in:
imccyu
2026-07-29 00:46:56 +08:00
parent cb8968c80e
commit 37f34af15f
6 changed files with 70 additions and 29 deletions

View File

@@ -23,13 +23,11 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
* and between-step seams used by the loop.
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { active, status = 'running' }: { active?: boolean; status?: 'idle' | 'running' } = {}): Promise<Agent & { session: Session }> {
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).
// Boundary tests default to a running agent (the mid-turn shape); the
// idle-commit tests pass status: 'idle' explicitly.
const session = new Session(SessionId(id))
const agent = { id: SessionId(id), session, options: {}, status } as unknown as Agent & { session: Session }
const agent = { id: SessionId(id), session, options: {} } as unknown as Agent & { session: Session }
let scoped!: Context
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
inject: ['tools'],
@@ -75,6 +73,16 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type:
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). */
function openTurn(session: Session, turn = 0): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
}
/** Close the open turn (the between-turns shape: selections commit immediately). */
function closeTurn(session: Session, turn = 0): void {
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
function header(session: Session): void {
session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial' })
@@ -169,10 +177,11 @@ describe('ctx.planMode: get/set', () => {
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('selects inactive as the plan exit target while running', async () => {
it('selects inactive as the plan exit target during an open turn', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
agent.session.append('plan/mode', { active: true })
openTurn(agent.session)
expect(ctx.planMode.set(agent, false)).toBe('queued')
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
})
@@ -180,6 +189,7 @@ describe('ctx.planMode: get/set', () => {
it('drops a no-op set (target equals pending, else the current fold)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
openTurn(agent.session)
expect(ctx.planMode.set(agent, false)).toBe('noop')
expect(ctx.planMode.get(agent)).toEqual({ active: false })
expect(ctx.planMode.set(agent, true)).toBe('queued')
@@ -187,9 +197,9 @@ describe('ctx.planMode: get/set', () => {
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('an idle selection commits plan/mode immediately (no boundary would come)', async () => {
it('a between-turns selection commits plan/mode immediately (no boundary would come)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx, 'agent-idle', { status: 'idle' })
const agent = await agentWithSession(ctx, 'agent-idle')
expect(ctx.planMode.set(agent, true)).toBe('committed')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
@@ -201,20 +211,21 @@ describe('ctx.planMode: get/set', () => {
expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2)
})
it('an idle reversal of a mid-turn pending intent cancels without logging', async () => {
it('a between-turns reversal of a mid-turn pending intent cancels without logging', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
openTurn(agent.session)
expect(ctx.planMode.set(agent, true)).toBe('queued')
;(agent as { status: string }).status = 'idle'
closeTurn(agent.session)
// Back to the logged state: the pending intent clears, nothing lands.
expect(ctx.planMode.set(agent, false)).toBe('cancelled')
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
expect(ctx.planMode.get(agent)).toEqual({ active: false })
})
it('an idle commit narrates when the last header told the model otherwise', async () => {
it('a between-turns commit narrates when the last header told the model otherwise', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx, 'agent-idle-narrate', { status: 'idle' })
const agent = await agentWithSession(ctx, 'agent-idle-narrate')
header(agent.session)
ctx.planMode.set(agent, true)
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
@@ -225,6 +236,7 @@ describe('the boundary flush', () => {
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 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
@@ -243,6 +255,7 @@ describe('the boundary flush', () => {
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)
// A listener captured in the same dispatch snapshot keeps the plan-mode
// callback alive across the unload; the resumed wrapper must not append
@@ -261,6 +274,7 @@ describe('the boundary flush', () => {
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,
@@ -284,6 +298,7 @@ describe('the boundary flush', () => {
it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
ctx.planMode.set(agent, false)
await boundary(ctx, agent, 'turn/start')
@@ -338,6 +353,7 @@ describe('the boundary flush', () => {
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
// Only the flush's own plan/mode append fails; the boundary event itself
@@ -363,6 +379,7 @@ describe('the boundary flush', () => {
const warn = vi.fn()
ctx.logger.warn = warn as never
const agent = await agentWithSession(ctx)
openTurn(agent.session)
ctx.planMode.set(agent, true)
const original = agent.session.append.bind(agent.session)
agent.session.append = (((type: string, ...rest: unknown[]) => {
@@ -537,6 +554,7 @@ describe('/plan', () => {
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
await new Promise(resolve => setImmediate(resolve))
const plainAgent = await agentWithSession(ctx, 'plain-plan-command')
openTurn(plainAgent.session)
const plainSteer = vi.fn()
;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
expect(ctx.commands.list(plainAgent)).toEqual([
@@ -555,6 +573,7 @@ describe('/plan', () => {
expect(plainSteer).not.toHaveBeenCalled()
const messageAgent = await agentWithSession(ctx, 'message-plan-command')
openTurn(messageAgent.session)
const messageSteer = vi.fn()
;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
@@ -583,6 +602,7 @@ describe('/plan', () => {
expect(ctx.planMode.get(inactive)).toEqual({ active: false })
const entering = await agentWithSession(ctx, 'entering-plan-command')
openTurn(entering.session)
const enteringSteer = vi.fn()
;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer
await ctx.commands.execute(entering, '/plan', signal)
@@ -595,6 +615,7 @@ describe('/plan', () => {
expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)
const active = await agentWithSession(ctx, 'active-plan-command', { active: true })
openTurn(active.session)
const activeSteer = vi.fn()
;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer
expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
@@ -612,7 +633,7 @@ describe('/plan', () => {
await ctx.plugin(CommandService)
await new Promise(resolve => setImmediate(resolve))
const signal = new AbortController().signal
const agent = await agentWithSession(ctx, 'idle-plan-command', { status: 'idle' })
const agent = await agentWithSession(ctx, 'idle-plan-command')
expect((await ctx.commands.execute(agent, '/plan', signal))?.result)
.toEqual({ kind: 'success', text: 'Plan mode on. Use /plan off to leave.' })
expect(foldPlanMode(agent.session.events)).toBe(true)
@@ -956,6 +977,7 @@ describe('HMR disposal', () => {
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
const agent = await agentWithSession(ctx, 'disposed-recovery')
openTurn(agent.session)
ctx.planMode.set(agent, true)
expect(ctx.get('planMode')).toBeInstanceOf(PlanModeService)
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()