Merge branch 'codex/web-plan-wire' into codex/web-plan-mode

This commit is contained in:
fz
2026-07-24 16:34:56 +08:00
14 changed files with 252 additions and 21 deletions

View File

@@ -8,7 +8,7 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore
## Plan-mode projection
Each opened `Session` queries the optional plan capability independently of paginated history and exposes `planMode: null | { active, pending? }` in its `ConversationSnapshot`. `null` hides consumers that require the capability; a present `pending` differs from `active`. A successful selection replaces the snapshot with the host-confirmed committed and pending state; failures retain the previous state. A shared request fence drops stale plan query and selection responses, while an event-version fence preserves a commit that overtakes a current unary request. Logged live `plan/mode` events commit `active` and clear `pending`; replacement history windows also fold their latest plan event so gap repair cannot miss a recovered commit. Reconnect re-queries the full state, and a failed capability query never makes an otherwise usable conversation fail to open.
Each opened `Session` queries the optional plan capability independently of paginated history and exposes `planMode: null | { active, pending? }` in its `ConversationSnapshot`. `null` hides consumers that require the capability; a present `pending` differs from `active`. A successful selection replaces the snapshot with the host-confirmed committed and pending state; failures retain the previous state. Prompt admission waits for the latest selector request, follows a newer overlapping request when one supersedes it, stops on that latest selection's failure, and sends the resulting `pending ?? active` target with the prompt so the host cannot accept the message under another mode. A shared request fence drops stale plan query and selection responses, while an event-version fence preserves a commit that overtakes a current unary request. Logged live `plan/mode` events commit `active` and clear `pending`; replacement history windows also fold their latest plan event so gap repair cannot miss a recovered commit. Reconnect re-queries the full state, and a failed capability query never makes an otherwise usable conversation fail to open.
## Model Experience

View File

@@ -65,6 +65,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private planRequestVersion = 0
/** Latest valid commit, held until the initial capability query resolves. */
private latestLivePlanMode: PlanModeState | null = null
/**
* Latest selector mutation, retained after settlement so prompt admission
* cannot miss a failure that completed before it began waiting.
*/
private planSelection: Promise<RpcResult<PlanModeState | null>> | null = null
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
@@ -108,9 +113,30 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.promptError = null
this.lastAgentError = null
this.notifier.markDirty()
while (this.planSelection !== null) {
const pending = this.planSelection
const selection = await pending
// A newer selection owns the target and its outcome, even when this
// superseded request fails after the replacement has already settled.
if (this.planSelection !== pending) continue
if (!selection.ok) {
this.promptError = { op: 'send', error: selection.error }
this.notifier.markDirty()
return selection
}
break
}
const planTarget = this.planMode === null
? undefined
: this.planMode.pending ?? this.planMode.active
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
result = (await this.api.sessions.prompt({
sessionId: this.sessionId,
mode,
content,
...(planTarget === undefined ? {} : { planMode: planTarget }),
})).result
} catch (error) {
result = transportError(error)
}
@@ -147,7 +173,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* @param active Whether plan mode should be selected.
* @returns The host-confirmed state, or null when plan mode is unavailable.
*/
async setPlanMode(active: boolean): Promise<RpcResult<PlanModeState | null>> {
setPlanMode(active: boolean): Promise<RpcResult<PlanModeState | null>> {
const selection = this.selectPlanMode(active)
this.planSelection = selection
return selection
}
/** Run one selector mutation; {@link setPlanMode} retains its latest outcome for prompt admission. */
private async selectPlanMode(active: boolean): Promise<RpcResult<PlanModeState | null>> {
const planEventVersion = this.planEventVersion
const planRequestVersion = ++this.planRequestVersion
let result: RpcResult<PlanModeState | null>

View File

@@ -384,6 +384,100 @@ describe('prompt and cancel errors', () => {
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
})
it('waits for the current selector target and admits it with the prompt', async () => {
const { api, session } = makeSession()
api.onPlanMode = () => Promise.resolve(ok({ active: false }))
await session.open()
const selected = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
api.onSetPlanMode = () => selected.promise
const selecting = session.setPlanMode(true)
const prompting = session.prompt([{ type: 'text', text: 'plan this' }], 'queue')
await Promise.resolve()
expect(api.callsOf('session.prompt')).toEqual([])
selected.resolve(ok({ active: false, pending: true }))
await selecting
expect((await prompting).ok).toBe(true)
expect(api.callsOf('session.prompt')).toEqual([{
sessionId: SID,
mode: 'queue',
content: [{ type: 'text', text: 'plan this' }],
planMode: true,
}])
})
it('does not admit a prompt when the selector request fails', async () => {
const { api, session } = makeSession()
api.onPlanMode = () => Promise.resolve(ok({ active: false }))
await session.open()
const selected = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
api.onSetPlanMode = () => selected.promise
const selecting = session.setPlanMode(true)
const prompting = session.prompt([{ type: 'text', text: 'do not send' }], 'queue')
selected.resolve(err({ code: 'internal', message: 'selection failed', details: {} }))
await selecting
expect((await prompting).ok).toBe(false)
expect(api.callsOf('session.prompt')).toEqual([])
expect(session.getSnapshot().promptError).toMatchObject({
op: 'send',
error: { code: 'internal', message: 'selection failed' },
})
})
it('ignores a superseded selector failure and admits the latest successful target', async () => {
const { api, session } = makeSession()
api.onPlanMode = () => Promise.resolve(ok({ active: false }))
await session.open()
const older = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
const newer = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
let call = 0
api.onSetPlanMode = () => ++call === 1 ? older.promise : newer.promise
const selectPlan = session.setPlanMode(true)
const prompting = session.prompt([{ type: 'text', text: 'use latest' }], 'queue')
const selectDefault = session.setPlanMode(false)
newer.resolve(ok({ active: false }))
await selectDefault
older.resolve(err({ code: 'internal', message: 'stale failure', details: {} }))
await selectPlan
expect((await prompting).ok).toBe(true)
expect(api.callsOf('session.prompt')).toEqual([{
sessionId: SID,
mode: 'queue',
content: [{ type: 'text', text: 'use latest' }],
planMode: false,
}])
})
it('retains the latest selector failure until prompt admission observes it', async () => {
const { api, session } = makeSession()
api.onPlanMode = () => Promise.resolve(ok({ active: false }))
await session.open()
const older = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
const newer = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
let call = 0
api.onSetPlanMode = () => ++call === 1 ? older.promise : newer.promise
const selectPlan = session.setPlanMode(true)
const prompting = session.prompt([{ type: 'text', text: 'must stay local' }], 'queue')
const selectDefault = session.setPlanMode(false)
newer.resolve(err({ code: 'internal', message: 'latest failure', details: {} }))
await selectDefault
older.resolve(ok({ active: false, pending: true }))
await selectPlan
expect((await prompting).ok).toBe(false)
expect(api.callsOf('session.prompt')).toEqual([])
expect(session.getSnapshot().promptError).toMatchObject({
op: 'send',
error: { code: 'internal', message: 'latest failure' },
})
})
it('lands cancel failures in promptError with op=stop', async () => {
const { api, session } = makeSession()
api.onCancel = () => Promise.reject(new Error('cancel transport down'))