|
|
@@ -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()
|