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:
@@ -859,13 +859,19 @@ Source: [`packages/ui/permission/src/index.ts:97`](../../packages/ui/permission/
|
|||||||
get(agent: Agent): { active: boolean; pending?: boolean }
|
get(agent: Agent): { active: boolean; pending?: boolean }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select whether plan mode should be active from the next request boundary.
|
* Select whether plan mode should be active. An idle agent commits the
|
||||||
* Repeated selection of the current or already-pending state is a no-op.
|
* change immediately (no boundary would arrive until the next prompt); a
|
||||||
|
* running agent holds it as pending intent for the next in-turn request
|
||||||
|
* boundary. Repeated selection of the current or already-pending state is
|
||||||
|
* a no-op.
|
||||||
*
|
*
|
||||||
* @param agent The agent to switch.
|
* @param agent The agent to switch.
|
||||||
* @param active Whether plan mode should be active.
|
* @param active Whether plan mode should be active.
|
||||||
|
* @returns what happened: `committed` (logged now), `queued` (awaiting the
|
||||||
|
* next boundary), `cancelled` (an opposite pending selection was cleared;
|
||||||
|
* the logged state already matches), or `noop` (already in that state).
|
||||||
*/
|
*/
|
||||||
set(agent: Agent, active: boolean): void
|
set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop'
|
||||||
```
|
```
|
||||||
|
|
||||||
Types: [Agent](../core-data-structures/core.md)
|
Types: [Agent](../core-data-structures/core.md)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
|
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
|
||||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
||||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
|
||||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||||
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||||
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
|
||||||
|
|||||||
@@ -149,14 +149,14 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
|
|||||||
actions: [
|
actions: [
|
||||||
{ waitFor: 'main-session-', send: '/plan' },
|
{ waitFor: 'main-session-', send: '/plan' },
|
||||||
{ waitFor: '[off|message] — Enter or leave plan mode', send: '\r' },
|
{ waitFor: '[off|message] — Enter or leave plan mode', send: '\r' },
|
||||||
{ waitFor: 'Entering plan mode (applies from the next step). Use /plan off to leave.', send: '/exit\r' },
|
{ waitFor: 'Plan mode on. Use /plan off to leave.', send: '/exit\r' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
expect(output).toContain('DEEPSEEK')
|
expect(output).toContain('DEEPSEEK')
|
||||||
expect(output).toContain('HARNESS')
|
expect(output).toContain('HARNESS')
|
||||||
expect(output).toContain('main-session-')
|
expect(output).toContain('main-session-')
|
||||||
expect(output).toContain('[off|message] — Enter or leave plan mode')
|
expect(output).toContain('[off|message] — Enter or leave plan mode')
|
||||||
expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.')
|
expect(output).toContain('Plan mode on. Use /plan off to leave.')
|
||||||
// Borderless: no box-drawing frame around the banner.
|
// Borderless: no box-drawing frame around the banner.
|
||||||
expect(output).not.toContain('╭')
|
expect(output).not.toContain('╭')
|
||||||
expect(output).not.toContain('╮')
|
expect(output).not.toContain('╮')
|
||||||
@@ -183,15 +183,15 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
|
|||||||
// Gating /status on it keeps the assertion race-free; the diagnostics
|
// Gating /status on it keeps the assertion race-free; the diagnostics
|
||||||
// card is then exercised through the same real Loader/PTY composition.
|
// card is then exercised through the same real Loader/PTY composition.
|
||||||
{ waitFor: 'scripted session title — DeepSeek Harness', send: '/plan off\r' },
|
{ waitFor: 'scripted session title — DeepSeek Harness', send: '/plan off\r' },
|
||||||
{ waitFor: 'Leaving plan mode (applies from the next step).', send: 'Confirm the scripted run left plan mode.\r' },
|
{ waitFor: 'Plan mode off.', send: 'Confirm the scripted run left plan mode.\r' },
|
||||||
{ waitFor: 'Default mode confirmed.', send: '/status\r' },
|
{ waitFor: 'Default mode confirmed.', send: '/status\r' },
|
||||||
{ waitFor: 'Session status', send: '/exit\r' },
|
{ waitFor: 'Session status', send: '/exit\r' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
expect(output).toContain('I need one decision before I continue.')
|
expect(output).toContain('I need one decision before I continue.')
|
||||||
expect(output).toContain('Reasoning effort: Max.')
|
expect(output).toContain('Reasoning effort: Max.')
|
||||||
expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.')
|
expect(output).toContain('Plan mode on. Use /plan off to leave.')
|
||||||
expect(output).toContain('Leaving plan mode (applies from the next step).')
|
expect(output).toContain('Plan mode off.')
|
||||||
expect(output).toContain('Default mode confirmed.')
|
expect(output).toContain('Default mode confirmed.')
|
||||||
expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`)
|
expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`)
|
||||||
expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`)
|
expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`)
|
||||||
|
|||||||
@@ -433,8 +433,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
jsDoc: '/**\n * Read the logged plan state and any selected state awaiting a boundary.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */',
|
jsDoc: '/**\n * Read the logged plan state and any selected state awaiting a boundary.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
signature: 'set(agent: Agent, active: boolean): void',
|
signature: 'set(agent: Agent, active: boolean): \'committed\' | \'queued\' | \'cancelled\' | \'noop\'',
|
||||||
jsDoc: '/**\n * Select whether plan mode should be active from the next request boundary.\n * Repeated selection of the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n */',
|
jsDoc: '/**\n * Select whether plan mode should be active. An idle agent commits the\n * change immediately (no boundary would arrive until the next prompt); a\n * running agent holds it as pending intent for the next in-turn request\n * boundary. Repeated selection of the current or already-pending state is\n * a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n * @returns what happened: `committed` (logged now), `queued` (awaiting the\n * next boundary), `cancelled` (an opposite pending selection was cleared;\n * the logged state already matches), or `noop` (already in that state).\n */',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ const planProjectionSchema: ZodType<PlanProjection> = zod.object({
|
|||||||
pending: zod.boolean(),
|
pending: zod.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Whether the log holds an opened turn without its closing `turn/end`. */
|
||||||
|
function hasOpenTurn(events: readonly SessionEvent[]): boolean {
|
||||||
|
let open = false
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === 'turn/start') open = true
|
||||||
|
else if (event.type === 'turn/end') open = false
|
||||||
|
}
|
||||||
|
return open
|
||||||
|
}
|
||||||
|
|
||||||
/** Plan state at the last logged request header, or `undefined` before the first header. */
|
/** Plan state at the last logged request header, or `undefined` before the first header. */
|
||||||
function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined {
|
function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined {
|
||||||
let lastHeader = -1
|
let lastHeader = -1
|
||||||
@@ -364,9 +374,12 @@ export class PlanModeService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select whether plan mode should be active. An idle agent commits the
|
* Select whether plan mode should be active. Between turns the change
|
||||||
* change immediately (no boundary would arrive until the next prompt); a
|
* commits immediately — no request boundary would arrive until the next
|
||||||
* running agent holds it as pending intent for the next in-turn request
|
* prompt, so a queued intent would hang (the open-turn fold is the idle
|
||||||
|
* signal: agent status stays `running` through post-turn checkpointing,
|
||||||
|
* where a boundary equally never comes). During an open turn the
|
||||||
|
* selection is held as pending intent for the next in-turn request
|
||||||
* boundary. Repeated selection of the current or already-pending state is
|
* boundary. Repeated selection of the current or already-pending state is
|
||||||
* a no-op.
|
* a no-op.
|
||||||
*
|
*
|
||||||
@@ -381,12 +394,12 @@ export class PlanModeService extends Service {
|
|||||||
const pending = this.pendingIntents.get(session)
|
const pending = this.pendingIntents.get(session)
|
||||||
const target = pending?.active ?? foldPlanMode(session.events)
|
const target = pending?.active ?? foldPlanMode(session.events)
|
||||||
if (active === target) return 'noop'
|
if (active === target) return 'noop'
|
||||||
if (agent.status === 'running') {
|
if (hasOpenTurn(session.events)) {
|
||||||
this.pendingIntents.set(session, { active, narrate: true })
|
this.pendingIntents.set(session, { active, narrate: true })
|
||||||
return foldPlanMode(session.events) === active ? 'cancelled' : 'queued'
|
return foldPlanMode(session.events) === active ? 'cancelled' : 'queued'
|
||||||
}
|
}
|
||||||
// Idle: commit now. Delete only after append succeeds so a failed durable
|
// No open turn: commit now. Delete only after append succeeds so a
|
||||||
// write leaves the selection retryable rather than silently dropped.
|
// failed durable write leaves the selection retryable, not dropped.
|
||||||
if (active === foldPlanMode(session.events)) {
|
if (active === foldPlanMode(session.events)) {
|
||||||
this.pendingIntents.delete(session)
|
this.pendingIntents.delete(session)
|
||||||
return 'cancelled'
|
return 'cancelled'
|
||||||
|
|||||||
@@ -23,13 +23,11 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
|
|||||||
* and between-step seams used by the loop.
|
* 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
|
// A live store session when a store is mounted (the command executor logs
|
||||||
// lifecycle events through it); bare otherwise (fold/tool-only benches).
|
// 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 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
|
let scoped!: Context
|
||||||
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
|
await ctx.plugin(Object.assign((inner: Context) => { scoped = createScope(inner, agent).ctx }, {
|
||||||
inject: ['tools'],
|
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)
|
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. */
|
/** Append a minimal `request/header` snapshot so the log has a "what the model was told" anchor. */
|
||||||
function header(session: Session): void {
|
function header(session: Session): void {
|
||||||
session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial' })
|
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 })
|
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 ctx = await setup()
|
||||||
const agent = await agentWithSession(ctx)
|
const agent = await agentWithSession(ctx)
|
||||||
agent.session.append('plan/mode', { active: true })
|
agent.session.append('plan/mode', { active: true })
|
||||||
|
openTurn(agent.session)
|
||||||
expect(ctx.planMode.set(agent, false)).toBe('queued')
|
expect(ctx.planMode.set(agent, false)).toBe('queued')
|
||||||
expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false })
|
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 () => {
|
it('drops a no-op set (target equals pending, else the current fold)', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const agent = await agentWithSession(ctx)
|
const agent = await agentWithSession(ctx)
|
||||||
|
openTurn(agent.session)
|
||||||
expect(ctx.planMode.set(agent, false)).toBe('noop')
|
expect(ctx.planMode.set(agent, false)).toBe('noop')
|
||||||
expect(ctx.planMode.get(agent)).toEqual({ active: false })
|
expect(ctx.planMode.get(agent)).toEqual({ active: false })
|
||||||
expect(ctx.planMode.set(agent, true)).toBe('queued')
|
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 })
|
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 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(ctx.planMode.set(agent, true)).toBe('committed')
|
||||||
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 })
|
||||||
@@ -201,20 +211,21 @@ describe('ctx.planMode: get/set', () => {
|
|||||||
expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2)
|
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 ctx = await setup()
|
||||||
const agent = await agentWithSession(ctx)
|
const agent = await agentWithSession(ctx)
|
||||||
|
openTurn(agent.session)
|
||||||
expect(ctx.planMode.set(agent, true)).toBe('queued')
|
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.
|
// Back to the logged state: the pending intent clears, nothing lands.
|
||||||
expect(ctx.planMode.set(agent, false)).toBe('cancelled')
|
expect(ctx.planMode.set(agent, false)).toBe('cancelled')
|
||||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||||
expect(ctx.planMode.get(agent)).toEqual({ active: 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 ctx = await setup()
|
||||||
const agent = await agentWithSession(ctx, 'agent-idle-narrate', { status: 'idle' })
|
const agent = await agentWithSession(ctx, 'agent-idle-narrate')
|
||||||
header(agent.session)
|
header(agent.session)
|
||||||
ctx.planMode.set(agent, true)
|
ctx.planMode.set(agent, true)
|
||||||
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.'])
|
||||||
@@ -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 () => {
|
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)
|
||||||
|
openTurn(agent.session)
|
||||||
ctx.planMode.set(agent, true)
|
ctx.planMode.set(agent, true)
|
||||||
// Prompt admission runs before any turn opens; a plan/mode appended there
|
// Prompt admission runs before any turn opens; a plan/mode appended there
|
||||||
// would sit outside the turn. The pending intent survives admission and
|
// would sit outside the turn. The pending intent survives admission and
|
||||||
@@ -243,6 +255,7 @@ describe('the boundary flush', () => {
|
|||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
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)
|
||||||
|
openTurn(agent.session)
|
||||||
ctx.planMode.set(agent, true)
|
ctx.planMode.set(agent, true)
|
||||||
// A listener captured in the same dispatch snapshot keeps the plan-mode
|
// A listener captured in the same dispatch snapshot keeps the plan-mode
|
||||||
// callback alive across the unload; the resumed wrapper must not append
|
// callback alive across the unload; the resumed wrapper must not append
|
||||||
@@ -261,6 +274,7 @@ describe('the boundary flush', () => {
|
|||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
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)
|
||||||
|
openTurn(agent.session)
|
||||||
ctx.planMode.set(agent, true)
|
ctx.planMode.set(agent, true)
|
||||||
// Serial dispatch captures its listener list up front; prepending after
|
// Serial dispatch captures its listener list up front; prepending after
|
||||||
// the plugin puts this listener ahead of the plugin's own prepended one,
|
// 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 () => {
|
it('nets out a flip sequence that returns to the folded mode (no append, no notice)', async () => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const agent = await agentWithSession(ctx)
|
const agent = await agentWithSession(ctx)
|
||||||
|
openTurn(agent.session)
|
||||||
ctx.planMode.set(agent, true)
|
ctx.planMode.set(agent, true)
|
||||||
ctx.planMode.set(agent, false)
|
ctx.planMode.set(agent, false)
|
||||||
await boundary(ctx, agent, 'turn/start')
|
await boundary(ctx, agent, 'turn/start')
|
||||||
@@ -338,6 +353,7 @@ describe('the boundary flush', () => {
|
|||||||
const warn = vi.fn()
|
const warn = vi.fn()
|
||||||
ctx.logger.warn = warn as never
|
ctx.logger.warn = warn as never
|
||||||
const agent = await agentWithSession(ctx)
|
const agent = await agentWithSession(ctx)
|
||||||
|
openTurn(agent.session)
|
||||||
ctx.planMode.set(agent, true)
|
ctx.planMode.set(agent, true)
|
||||||
const original = agent.session.append.bind(agent.session)
|
const original = agent.session.append.bind(agent.session)
|
||||||
// Only the flush's own plan/mode append fails; the boundary event itself
|
// 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()
|
const warn = vi.fn()
|
||||||
ctx.logger.warn = warn as never
|
ctx.logger.warn = warn as never
|
||||||
const agent = await agentWithSession(ctx)
|
const agent = await agentWithSession(ctx)
|
||||||
|
openTurn(agent.session)
|
||||||
ctx.planMode.set(agent, true)
|
ctx.planMode.set(agent, true)
|
||||||
const original = agent.session.append.bind(agent.session)
|
const original = agent.session.append.bind(agent.session)
|
||||||
agent.session.append = (((type: string, ...rest: unknown[]) => {
|
agent.session.append = (((type: string, ...rest: unknown[]) => {
|
||||||
@@ -537,6 +554,7 @@ describe('/plan', () => {
|
|||||||
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
|
// The `ctx.inject` child mounts asynchronously once `commands` resolves.
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
const plainAgent = await agentWithSession(ctx, 'plain-plan-command')
|
const plainAgent = await agentWithSession(ctx, 'plain-plan-command')
|
||||||
|
openTurn(plainAgent.session)
|
||||||
const plainSteer = vi.fn()
|
const plainSteer = vi.fn()
|
||||||
;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
|
;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer
|
||||||
expect(ctx.commands.list(plainAgent)).toEqual([
|
expect(ctx.commands.list(plainAgent)).toEqual([
|
||||||
@@ -555,6 +573,7 @@ describe('/plan', () => {
|
|||||||
expect(plainSteer).not.toHaveBeenCalled()
|
expect(plainSteer).not.toHaveBeenCalled()
|
||||||
|
|
||||||
const messageAgent = await agentWithSession(ctx, 'message-plan-command')
|
const messageAgent = await agentWithSession(ctx, 'message-plan-command')
|
||||||
|
openTurn(messageAgent.session)
|
||||||
const messageSteer = vi.fn()
|
const messageSteer = vi.fn()
|
||||||
;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
|
;(messageAgent as unknown as { steer: typeof messageSteer }).steer = messageSteer
|
||||||
const plan = await ctx.commands.execute(messageAgent, '/plan draft the migration ', signal)
|
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 })
|
expect(ctx.planMode.get(inactive)).toEqual({ active: false })
|
||||||
|
|
||||||
const entering = await agentWithSession(ctx, 'entering-plan-command')
|
const entering = await agentWithSession(ctx, 'entering-plan-command')
|
||||||
|
openTurn(entering.session)
|
||||||
const enteringSteer = vi.fn()
|
const enteringSteer = vi.fn()
|
||||||
;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer
|
;(entering as unknown as { steer: typeof enteringSteer }).steer = enteringSteer
|
||||||
await ctx.commands.execute(entering, '/plan', signal)
|
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)
|
expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false)
|
||||||
|
|
||||||
const active = await agentWithSession(ctx, 'active-plan-command', { active: true })
|
const active = await agentWithSession(ctx, 'active-plan-command', { active: true })
|
||||||
|
openTurn(active.session)
|
||||||
const activeSteer = vi.fn()
|
const activeSteer = vi.fn()
|
||||||
;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer
|
;(active as unknown as { steer: typeof activeSteer }).steer = activeSteer
|
||||||
expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
|
expect((await ctx.commands.execute(active, '/plan off', signal))?.result)
|
||||||
@@ -612,7 +633,7 @@ describe('/plan', () => {
|
|||||||
await ctx.plugin(CommandService)
|
await ctx.plugin(CommandService)
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
const signal = new AbortController().signal
|
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)
|
expect((await ctx.commands.execute(agent, '/plan', signal))?.result)
|
||||||
.toEqual({ kind: 'success', text: 'Plan mode on. Use /plan off to leave.' })
|
.toEqual({ kind: 'success', text: 'Plan mode on. Use /plan off to leave.' })
|
||||||
expect(foldPlanMode(agent.session.events)).toBe(true)
|
expect(foldPlanMode(agent.session.events)).toBe(true)
|
||||||
@@ -956,6 +977,7 @@ describe('HMR disposal', () => {
|
|||||||
await ctx.plugin(ToolRegistry)
|
await ctx.plugin(ToolRegistry)
|
||||||
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
|
||||||
const agent = await agentWithSession(ctx, 'disposed-recovery')
|
const agent = await agentWithSession(ctx, 'disposed-recovery')
|
||||||
|
openTurn(agent.session)
|
||||||
ctx.planMode.set(agent, true)
|
ctx.planMode.set(agent, true)
|
||||||
expect(ctx.get('planMode')).toBeInstanceOf(PlanModeService)
|
expect(ctx.get('planMode')).toBeInstanceOf(PlanModeService)
|
||||||
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
|
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
|
||||||
|
|||||||
Reference in New Issue
Block a user