feat: persist context snapshots and defer permission changes to next messages

This commit is contained in:
Codex committed 2026-10-21 17:32:41 +09:00
1 parent 607afd2f39
commit 1a913cdb67
19 files changed
+510 -66

No files matched your search

+82 -10
View File
@@ -341,7 +341,7 @@ async function testLateResultAndEventStayInOriginalSession(): Promise<void> {
}
async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise<void> {
resetStore([], null, [], { modelId: 'draft-a', thinkingLevel: 'low' })
resetStore([], null, [], { modelId: 'draft-a', thinkingLevel: 'low', permissionMode: 'default-sandbox' })
const backend = installFakeBackend([], {})
const createGate = deferred<SessionMeta>()
const updateGate = deferred<SessionMeta>()
@@ -357,26 +357,28 @@ async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise<void> {
updateCapture = { id: sessionId, patch: { ...patch } }
return updateGate.promise
}
backend.api.chat.send = async () => {
backend.api.chat.send = async payload => {
assert.equal(payload.permissionMode, 'default-sandbox', 'first submission retains original policy')
sendCalls++
return makeSendResult('run-first', 'draft-b', 'Draft B')
}
const sending = useChatStore.getState().send('first message')
await waitFor('first session creation', () => createContext !== undefined)
assert.deepEqual(createContext, { modelId: 'draft-a', thinkingLevel: 'low' })
assert.deepEqual(createContext, { modelId: 'draft-a', thinkingLevel: 'low', permissionMode: 'default-sandbox' })
await useChatStore.getState().selectModel('draft-b')
await useChatStore.getState().selectThinkingLevel('high')
assert.deepEqual(useChatStore.getState().draftContext, { modelId: 'draft-b', thinkingLevel: 'high' })
await useChatStore.getState().selectPermissionMode('full-access')
assert.deepEqual(useChatStore.getState().draftContext, { modelId: 'draft-b', thinkingLevel: 'high', permissionMode: 'full-access' })
const created = makeSession('created-session', { modelId: 'draft-a', thinkingLevel: 'low' })
const created = makeSession('created-session', { modelId: 'draft-a', thinkingLevel: 'low', permissionMode: 'default-sandbox' })
backend.replaceSession(created)
createGate.resolve(created)
await waitFor('draft context patch after session creation', () => updateCapture !== undefined)
assert.deepEqual(updateCapture, {
id: 'created-session',
patch: { modelId: 'draft-b', thinkingLevel: 'high' }
patch: { modelId: 'draft-b', thinkingLevel: 'high', permissionMode: 'full-access' }
})
assert.equal(sendCalls, 0, 'the first send must wait for the newer draft settings to persist')
@@ -389,28 +391,94 @@ async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise<void> {
assert.equal(state.currentSessionId, 'created-session')
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.modelId, 'draft-b')
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.thinkingLevel, 'high')
assert.deepEqual(state.draftContext, { modelId: 'draft-b', thinkingLevel: 'high' })
assert.deepEqual(state.draftContext, { modelId: 'draft-b', thinkingLevel: 'high', permissionMode: 'full-access' })
assert.equal(state.sessions.find(item => item.id === 'created-session')?.permissionMode, 'full-access')
assert.equal(sendCalls, 1)
}
async function testPermissionSnapshots(): Promise<void> {
for (const [before, after] of [['default-sandbox', 'full-access'], ['full-access', 'default-sandbox']] as const) {
const main = makeSession(`permissions-${before}`, { modelId: 'model-a', permissionMode: before })
const other = makeSession(`other-${before}`, { modelId: 'model-a', permissionMode: before })
resetStore([main, other], main.id)
const backend = installFakeBackend([main, other])
backend.api.chat.send = async () => makeSendResult('permission-run', 'model-a', 'Model A')
await useChatStore.getState().send('initial')
const active = useChatStore.getState().running
const queued: SendPayload[] = []
backend.api.chat.queue.enqueue = async payload => { queued.push(structuredClone(payload)) }
await useChatStore.getState().send('old queued')
const originalWrite = backend.api.session.updateContext
const gate = deferred<SessionMeta>()
backend.api.session.updateContext = () => gate.promise
const choice = useChatStore.getState().selectPermissionMode(after)
const nextSend = useChatStore.getState().send('new queued')
await flushAsync()
assert.equal(queued.length, 1, 'submission waits for its pending preference save')
assert.equal(useChatStore.getState().sessions[0].permissionMode, before, 'no optimistic permission grant')
await useChatStore.getState().selectSession(other.id)
gate.resolve({ ...main, permissionMode: after })
await choice
await nextSend
assert.equal(useChatStore.getState().currentSessionId, other.id)
assert.equal(useChatStore.getState().sessions.find(item => item.id === other.id)?.permissionMode, before)
assert.equal(useChatStore.getState().runs[active!.runId], active, 'running turn identity is untouched')
assert.deepEqual(queued.map(item => item.permissionMode), [before, after], 'each enqueue owns its captured policy')
assert.ok(queued.every(item => item.sessionId === main.id))
backend.api.session.updateContext = originalWrite
await useChatStore.getState().selectSession(main.id)
useChatStore.setState({ running: active, runs: { [active!.runId]: active! } })
backend.api.session.updateContext = async () => { throw new Error('permission save failed') }
await assert.rejects(useChatStore.getState().selectPermissionMode(before), /permission save failed/)
assert.equal(useChatStore.getState().sessions.find(item => item.id === main.id)?.permissionMode, after)
await assert.rejects(useChatStore.getState().send('must not silently use failed choice'), /permission save failed/)
backend.api.session.updateContext = originalWrite
await useChatStore.getState().selectPermissionMode(before)
await useChatStore.getState().send('retry queued')
assert.equal(queued.at(-1)?.permissionMode, before)
for (const agentMode of ['plan', 'ask'] as const) {
useChatStore.setState(state => ({ sessions: state.sessions.map(item => item.id === main.id ? { ...item, agentMode } : item) }))
await assert.rejects(useChatStore.getState().selectPermissionMode(after), /计划和仅问答/)
}
}
}
async function testPermissionCaptureBeforeLaterToggle(): Promise<void> {
const main = makeSession('capture-at-submit', { modelId: 'model-a', permissionMode: 'default-sandbox' })
resetStore([main], main.id)
const backend = installFakeBackend([main])
const gate = deferred<{ items: unknown[] }>()
backend.api.chat.queue.get = () => gate.promise
let sent: SendPayload | undefined
backend.api.chat.send = async payload => { sent = payload; return makeSendResult('capture-run', 'model-a', 'Model A') }
const pending = useChatStore.getState().send('already submitted')
await flushAsync()
await useChatStore.getState().selectPermissionMode('full-access')
gate.resolve({ items: [] })
await pending
assert.equal(sent?.permissionMode, 'default-sandbox', 'later choice cannot rewrite in-flight submission')
assert.equal(useChatStore.getState().sessions[0].permissionMode, 'full-access', 'new choice remains for next submission')
}
function testNextRequestSettingsWhitelist(): void {
assert.deepEqual(nextRequestSettings({ modelId: 'model-next', thinkingLevel: 'high' }), {
modelId: 'model-next',
thinkingLevel: 'high'
})
assert.deepEqual(nextRequestSettings({}), {})
assert.deepEqual(nextRequestSettings({ permissionMode: 'full-access' }), { permissionMode: 'full-access' })
assert.throws(() => nextRequestSettings({ permissionMode: 'invalid' as never }), /权限模式无效/)
const forbidden: SessionContext[] = [
{ permissionMode: 'full-access' },
{ cwd: 'C:\\workspace' },
{ agentMode: 'plan' },
{ projectId: 'project-1' },
{ workspaceId: 'workspace-1' },
{ expertId: 'expert-1' },
{ modelId: 'model-next', permissionMode: 'default-sandbox' }
]
for (const patch of forbidden) {
assert.throws(() => nextRequestSettings(patch), /运行期间只能修改下次发送的模型和思考强度/)
assert.throws(() => nextRequestSettings(patch), /运行期间只能修改下次发送的模型、思考强度和权限/)
}
assert.throws(() => nextRequestSettings({ modelId: ' ' }), /模型标识无效/)
assert.throws(() => nextRequestSettings({ thinkingLevel: 'invalid' as never }), /无效的思考强度/)
@@ -431,6 +499,10 @@ try {
completed.push('late result/event session isolation')
await testDraftContextWinsWhileFirstSessionIsCreated()
completed.push('draft settings during first session creation')
await testPermissionSnapshots()
completed.push('permission snapshots both directions + save rollback + session isolation + plan/ask')
await testPermissionCaptureBeforeLaterToggle()
completed.push('permission captured before later toggle')
testNextRequestSettingsWhitelist()
completed.push('nextRequestSettings whitelist')
console.log(JSON.stringify({ result: 'PASS', tests: completed }, null, 2))