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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user