feat: persist context snapshots and defer permission changes to next messages
This commit is contained in:
1 parent
607afd2f39
commit
1a913cdb67
19 files changed
+510
-66
No files matched your search
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user