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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user