fix(plan): commit an idle selection immediately

set() on an idle agent appends plan/mode at once — no request boundary
would arrive until the next prompt, so a queued intent used to hang as
pending forever (the composer showed a dead pending target). A running
agent keeps the boundary-flush path unchanged. set() now reports which
branch ran (committed/queued/cancelled/noop); the /plan handler's copy
follows the branch (idle: "Plan mode on/off", mid-turn: the next-step
wording), and both commit paths share the header-delta narration. The
invariant drops turn enclosure: plan/mode is a standalone whole-value
event (the synthetic log-only turns removal already established the
between-turns append shape). The fixture mirrors the idle commit.
This commit is contained in:
imccyu
2026-07-28 23:09:15 +08:00
parent afaa9ad828
commit 970b432227
8 changed files with 123 additions and 59 deletions

View File

@@ -44,10 +44,10 @@ describe('plan-mode stream invariants', () => {
.toThrow(/expected a boolean/)
})
it('rejects plan state outside any open turn', async () => {
it('accepts standalone plan state between turns (the idle immediate commit)', async () => {
const ctx = await setup()
expect(() => ctx.sessions.create().append('plan/mode', { active: true }))
.toThrow(/outside any open turn/)
.not.toThrow()
})
it('ignores unrelated dispatches and session events', async () => {
@@ -85,12 +85,12 @@ describe('plan-mode stream invariants', () => {
await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).resolves.toBeUndefined()
})
it('rejects unenclosed existing plan state on late registration', async () => {
it('accepts standalone existing plan state on late registration', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.sessions.create().append('plan/mode', { active: true })
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
await expect(ctx.plugin(PlanModeInvariant).then(() => undefined)).resolves.toBeUndefined()
})
})

View File

@@ -23,11 +23,13 @@ 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 }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
async function agentWithSession(ctx: Context, id = 'agent-1', { active, status = 'running' }: { active?: boolean; status?: 'idle' | 'running' } = {}): 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: {} } as unknown as Agent & { session: Session }
const agent = { id: SessionId(id), session, options: {}, status } as unknown as Agent & { session: Session }
let scoped!: Context
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
inject: ['tools'],
@@ -167,23 +169,56 @@ describe('ctx.planMode: get/set', () => {
expect(ctx.planMode.get(agent)).toEqual({ active: true })
})
it('selects inactive as the plan exit target', async () => {
it('selects inactive as the plan exit target while running', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
agent.session.append('plan/mode', { active: true })
ctx.planMode.set(agent, false)
expect(ctx.planMode.set(agent, false)).toBe('queued')
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
})
it('drops a no-op set (target equals pending, else the current fold)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
ctx.planMode.set(agent, false)
expect(ctx.planMode.set(agent, false)).toBe('noop')
expect(ctx.planMode.get(agent)).toEqual({ active: false })
ctx.planMode.set(agent, true)
ctx.planMode.set(agent, true)
expect(ctx.planMode.set(agent, true)).toBe('queued')
expect(ctx.planMode.set(agent, true)).toBe('noop')
expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('an idle selection commits plan/mode immediately (no boundary would come)', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx, 'agent-idle', { status: 'idle' })
expect(ctx.planMode.set(agent, true)).toBe('committed')
expect(foldPlanMode(agent.session.events)).toBe(true)
expect(ctx.planMode.get(agent)).toEqual({ active: true })
// Immediately reversible, still without a boundary.
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')
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 () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
expect(ctx.planMode.set(agent, true)).toBe('queued')
;(agent as { status: string }).status = 'idle'
// 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 () => {
const ctx = await setup()
const agent = await agentWithSession(ctx, 'agent-idle-narrate', { status: 'idle' })
header(agent.session)
ctx.planMode.set(agent, true)
expect(noticeTexts(agent.session)).toEqual(['The user switched this session to plan mode.'])
})
})
describe('the boundary flush', () => {