Merge branch 'codex/web-plan-wire' into codex/web-plan-mode
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user