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

@@ -249,21 +249,28 @@ export class PlanModeService extends Service {
handler: ({ agent, rawInput }) => {
const message = rawInput.trim()
if (message === 'off') {
const state = this.get(agent)
this.set(agent, false)
if (state.active) {
return { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
switch (this.set(agent, false)) {
case 'committed':
return { kind: 'success', text: 'Plan mode off.' }
case 'queued':
return { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
case 'cancelled':
return { kind: 'success', text: 'Plan mode entry cancelled.' }
case 'noop':
// Repeat the queued wording while an exit still awaits its
// boundary; only a truly inactive session reads idempotent.
return foldPlanMode(agent.session.events)
? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
: { kind: 'success', text: 'Plan mode is already inactive.' }
}
if (state.pending === true) {
return { kind: 'success', text: 'Plan mode entry cancelled.' }
}
return { kind: 'success', text: 'Plan mode is already inactive.' }
}
this.set(agent, true)
const outcome = this.set(agent, true)
if (message !== '') agent.steer(createUserMessage({ content: [{ type: 'text', text: message }], source: { kind: 'user' } }))
return {
kind: 'success',
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
text: outcome === 'committed'
? 'Plan mode on. Use /plan off to leave.'
: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
}
},
})
@@ -357,17 +364,37 @@ export class PlanModeService extends Service {
}
/**
* Select whether plan mode should be active from the next request boundary.
* Repeated selection of the current or already-pending state is a no-op.
* Select whether plan mode should be active. An idle agent commits the
* 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 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' {
const session = agent.session
const target = this.pendingIntents.get(session)?.active ?? foldPlanMode(session.events)
if (active === target) return
this.pendingIntents.set(session, { active, narrate: true })
const pending = this.pendingIntents.get(session)
const target = pending?.active ?? foldPlanMode(session.events)
if (active === target) return 'noop'
if (agent.status === 'running') {
this.pendingIntents.set(session, { active, narrate: true })
return foldPlanMode(session.events) === active ? 'cancelled' : 'queued'
}
// Idle: commit now. Delete only after append succeeds so a failed durable
// write leaves the selection retryable rather than silently dropped.
if (active === foldPlanMode(session.events)) {
this.pendingIntents.delete(session)
return 'cancelled'
}
session.append('plan/mode', { active })
this.pendingIntents.delete(session)
this.narrate(session, active)
return 'committed'
}
/** Flush one pending selection before the next request assembly. */
@@ -384,7 +411,11 @@ export class PlanModeService extends Service {
// Delete only after append succeeds so a later boundary can retry a failed
// durable write.
this.pendingIntents.delete(session)
if (!pending.narrate) return
if (pending.narrate) this.narrate(session, target)
}
/** Tell the model about a user switch when the last logged header described the other mode. */
private narrate(session: Session, target: boolean): void {
const told = planModeAtLastHeader(session.events)
if (told === undefined || told === target) return
const text = target

View File

@@ -11,46 +11,33 @@ export const name = 'plan-mode-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Validate one `plan/mode` event before it reaches the durable log. */
function validateEvent(openTurn: number | null, event: SessionEvent, fail: InvariantFailure): void {
/**
* Validate one `plan/mode` event before it reaches the durable log.
* `plan/mode` is a standalone whole-value event: an idle selection commits
* between turns and a mid-turn selection commits at the step boundary, so
* no turn-enclosure relation exists — only the payload shape is checkable.
*/
function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
if (event.type !== 'plan/mode') return
if (openTurn === null) fail('plan/mode appended outside any open turn')
const active = (event.data as { active?: unknown }).active
if (typeof active !== 'boolean') {
fail(`plan/mode carries invalid active state ${JSON.stringify(active)}; expected a boolean`)
}
}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
/** Install validation for loaded and newly appended plan-mode state. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
const traces = new WeakMap<Session, number | null>()
const seed = (session: Session): number | null => {
let openTurn: number | null = null
traces.set(session, openTurn)
for (const event of session.events) {
if (event.type === 'turn/start') openTurn = event.data.turn
else if (event.type === 'turn/end') openTurn = null
validateEvent(openTurn, event, fail)
traces.set(session, openTurn)
}
return openTurn
const seed = (session: Session): void => {
for (const event of session.events) validateEvent(event, fail)
}
const traceFor = (session: Session): number | null => traces.get(session) ?? seed(session)
for (const session of ctx.sessions.list()) seed(session)
ctx.on('session/created', (session) => { seed(session) }, { global: true })
ctx.on('session/event', (session, event) => {
if (event.type === 'turn/start') traces.set(session, event.data.turn)
else if (event.type === 'turn/end') traces.set(session, null)
}, { global: true })
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
validateEvent(traceFor(session), event, fail)
const [, event] = args as [Session, SessionEvent]
validateEvent(event, fail)
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */
/**
* Register the plan-mode invariant companion.