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

+11
View File
@@ -695,6 +695,7 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
const result = sendResult('run-order')
const response = deferred<SendResult>()
let sendCalls = 0
const enteredSend = deferred<void>()
const events: ChatEvent[] = [
{ runId: 'other-run', type: 'text', delta: 'must be ignored' },
{ runId: result.runId, type: 'text', delta: 'before' },
@@ -715,6 +716,7 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
installFakeWindow(async () => {
sendCalls++
enteredSend.resolve()
for (const event of events) useChatStore.getState().applyEvent(event)
return response.promise
})
@@ -723,10 +725,19 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
assert.equal(useChatStore.getState().sending, true, 'sending should cover the pending IPC request')
assert.equal(useChatStore.getState().messages.length, 0, 'early events wait for the send result placeholder')
await assert.rejects(useChatStore.getState().send('duplicate'), /提交/)
// The duplicate rejects synchronously; preference persistence/capture may still
// be settling. Observe the actual IPC boundary rather than count microtasks.
let deadline: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([enteredSend.promise, new Promise<never>((_, reject) => {
deadline = setTimeout(() => reject(new Error('first send did not reach IPC')), 2000)
})])
} finally { clearTimeout(deadline) }
assert.equal(sendCalls, 1, 'a second send must be ignored while the first send is pending')
response.resolve(result)
await firstSend
assert.equal(sendCalls, 1, 'duplicate must never reach IPC after the first submission completes')
const message = assistantMessage()
assert.equal(message.turnStartedAt, undefined, 'terminal events clear the live timer timestamp')
+42 -3
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'
import { createContextUsageService, type ContextUsageDependencies } from '../src/main/context-usage-service'
import { createContextUsageService, contextSnapshotMatches, type ContextUsageDependencies } from '../src/main/context-usage-service'
import { contextBreakdownMethodLabel } from '../src/shared/context-usage'
import type { ChatMessage, ContextUsageBreakdown } from '../src/shared/types'
const sample = {
@@ -35,12 +36,16 @@ const breakdown: ContextUsageBreakdown = {
}
function service(overrides: Partial<ContextUsageDependencies> = {}) {
const messages = [assistant()]
const dependencies: ContextUsageDependencies = {
messages: async () => [assistant()],
messages: async () => messages,
modelId: async () => 'model-a',
running: () => false,
summary: async () => undefined,
persistIfCurrent: async () => undefined,
persistIfCurrent: async (_id, guard, summary) => {
const current = await dependencies.messages(_id)
if(contextSnapshotMatches(current, guard, summary))current.findLast(message=>message.role==='assistant')!.contextBreakdown=summary
},
...overrides
}
return createContextUsageService(dependencies)
@@ -71,3 +76,37 @@ const summaryFailure = await service({ summary: async () => { throw new Error('q
assert.equal(summaryFailure.sample?.usage.inputTokens, 1200, 'summary failures retain the real streamed usage sample')
console.log('context-usage-regression: latest, previous, active-run, summary identity and failure cases passed')
const cachedMessages = [assistant()]
const originalUsage = JSON.stringify(cachedMessages[0].contextUsage)
await service({messages:async()=>cachedMessages,summary:async()=>breakdown}).get('save')
assert.deepEqual(cachedMessages[0].contextBreakdown,breakdown)
assert.equal(JSON.stringify(cachedMessages[0].contextUsage),originalUsage,'saving categories never overwrites actual upstream usage')
const coldMessages=JSON.parse(JSON.stringify(cachedMessages)) as ChatMessage[]
for(const summary of [async()=>undefined,async()=>{throw new Error('kernel shut down')}]) {
const offline=await service({messages:async()=>coldMessages,summary}).get('offline')
assert.deepEqual(offline.breakdown,breakdown,'persisted categories survive cold/offline/no-instance queries')
assert.equal(offline.sample?.contextWindow,10_000)
}
const oldModel=await service({messages:async()=>coldMessages,modelId:async()=>'model-b'}).get('switch')
assert.equal(oldModel.status,'previous')
assert.equal(oldModel.sample?.contextWindow,10_000,'never combine old sample with the new selected model window')
assert.equal((await service({summary:async()=>({...breakdown,modelId:'wrong'})}).get('wrong-model')).breakdown,undefined)
for(const change of ['assistant','rewind','pending-user'] as const) {
let current=[assistant()]
const result=await service({messages:async()=>current,summary:async()=>{
if(change==='assistant')current.push(assistant({id:'new',requestId:'new-request',contextUsage:undefined}))
if(change==='rewind')current=[{id:'different-user',role:'user',content:'fork',createdAt:0},assistant()]
if(change==='pending-user')current.push({id:'pending',role:'user',content:'next',createdAt:2})
return breakdown
}}).get(change)
assert.equal(result.breakdown,undefined,`${change} while summary awaits cannot pollute the active branch`)
}
const pending=[assistant({contextBreakdown:breakdown}),{id:'pending',role:'user',content:'next',createdAt:2} as ChatMessage]
assert.equal((await service({messages:async()=>pending,summary:async()=>breakdown}).get('pending')).status,'previous','refresh must not erase pending-user previous status')
const categoriesOnly=await service({messages:async()=>[assistant({contextUsage:undefined,contextBreakdown:breakdown})]}).get('categories-only')
assert.equal(categoriesOnly.status,'latest','categories without upstream usage remain available, not fake zero usage')
assert.equal(categoriesOnly.sample,undefined)
assert.equal(contextBreakdownMethodLabel('native-summary'),'内核 summary 本地估计')
assert.equal(contextBreakdownMethodLabel('utf8-estimate'),'UTF-8 字节估计')
console.log('context-usage persistence: offline, shutdown, identity races, usage preservation, category provenance passed')
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import { renderToStaticMarkup } from 'react-dom/server'
import SourcePermissionControl from '../src/renderer/src/workbuddy/composer/permission/SourcePermissionControl'
for (const mode of ['default-sandbox', 'full-access'] as const) {
const render = (nextSend: boolean, disabled = false) => renderToStaticMarkup(<SourcePermissionControl
mode={mode} nextSend={nextSend} disabled={disabled} onChange={async () => { throw new Error('render must not mutate policy') }} onError={() => {}} />)
const busy = render(true)
assert.ok(busy.includes('下次发送生效'), 'busy selector must identify future policy, not claim current policy changed')
assert.ok(!busy.includes('disabled=""'), 'a running/sending turn must not disable preference selection')
assert.ok(!render(false).includes('下次发送生效'))
assert.ok(render(true, true).includes('disabled=""'), 'plan/ask and load guards remain enforceable')
}
console.log('Next-send permission labels and enabled/disabled static rendering passed')
@@ -370,16 +370,18 @@ try {
assert.equal(after.revision, before.revision, 'a blocked no-op must not persist or notify a new revision')
})
await verify('host resolver applies dispatch-time permission downgrade without mutating stored payload', async () => {
await verify('host resolver retains submission-time permission without mutating session preference', async () => {
const file = join(root, 'permission.json')
let blocked = true
let deliveredPermission: SendPayload['permissionMode']
assert.deepEqual(resolveQueuedRequestSettings({
queued: false, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox'
}), { permissionMode: 'full-access', persistSessionPermission: true })
}), { permissionMode: 'full-access', persistSessionPermission: false })
assert.deepEqual(resolveQueuedRequestSettings({
queued: true, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox'
}), { permissionMode: 'default-sandbox', persistSessionPermission: false })
}), { permissionMode: 'full-access', persistSessionPermission: false })
assert.deepEqual(resolveQueuedRequestSettings({ queued: true, payloadPermissionMode: 'default-sandbox', sessionPermissionMode: 'full-access' }), { permissionMode: 'default-sandbox', persistSessionPermission: false })
assert.deepEqual(resolveQueuedRequestSettings({ queued: true, sessionPermissionMode: 'full-access' }), { permissionMode: 'default-sandbox', persistSessionPermission: false }, 'legacy uncaptured queues fail safe')
const queue = createPromptQueue(options(file, {
blocked: () => blocked,
resolveDispatch: value => ({
@@ -400,7 +402,7 @@ try {
blocked = false
queue.notify('permission')
await waitFor(() => deliveredPermission !== undefined, 'permission delivery')
assert.equal(deliveredPermission, 'default-sandbox')
assert.equal(deliveredPermission, 'full-access')
await waitFor(async () => (await queue.get('permission')).items.length === 0, 'permission delivery cleanup')
})
} finally {
+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))
+53 -1
View File
@@ -11,7 +11,7 @@ interface SessionStoreApi {
listSessions(): Promise<SessionMeta[]>
createSession(title?: string): Promise<SessionMeta>
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
patchMessage(sessionId: string, messageId: string, patch: Partial<ChatMessage>): Promise<void>
patchMessage(sessionId: string, messageId: string, patch: Partial<ChatMessage>, options?: { preserveContextBreakdown?: boolean }): Promise<void>
}
interface IpcModule {
@@ -22,6 +22,7 @@ interface IpcModule {
interface RegressionState {
userData: string
handlers: Map<string, (...args: unknown[]) => unknown>
kernel?: {getContextSnapshotIdentity():unknown;getContextBreakdown():Promise<unknown>}
}
const STATE_KEY = '__YCODE_SESSION_STORE_REGRESSION__'
@@ -100,6 +101,20 @@ async function main(): Promise<void> {
logLevel: 'silent',
plugins: [
testTerminalHostPlugin,
{
name: 'context-summary-cached-kernel-mock',
setup(pluginBuild) {
pluginBuild.onResolve({filter:/\/claude-kernel$/},()=>({path:'kernel',namespace:'mock-summary-kernel'}))
pluginBuild.onLoad({filter:/.*/,namespace:'mock-summary-kernel'},()=>({loader:'js',contents:`
export const cachedClaudeKernel=()=>globalThis.${STATE_KEY}.kernel;
export const getClaudeKernel=()=>{throw new Error('context query must not start kernel')};
export const releaseClaudeKernel=async()=>{};
export const claudeRuntimeInfo=()=>({});
export const setSessionVisible=()=>{};
export const removeSessionView=()=>{};
`}))
}
},
{
name: 'session-store-electron-mock',
setup(pluginBuild) {
@@ -176,6 +191,35 @@ async function main(): Promise<void> {
})
}))
const snapshotSession = created[1]
const usage = {usage:{inputTokens:25,outputTokens:4,totalTokens:29},requestId:'snapshot-request',modelId:'snapshot-model',contextWindow:1000,sampledAt:5,source:'upstream' as const}
const savedBreakdown = {requestId:'snapshot-request',modelId:'snapshot-model',sequence:10,sampledAt:10,method:'native-summary' as const,scope:'prepared-request' as const,counts:{system:10,tools:0,conversation:15,mcp:0,skills:0,other:0},inputTokens:25,partial:true}
const snapshotMessage:ChatMessage={id:'snapshot-assistant',role:'assistant',content:'',createdAt:0,status:'streaming',requestId:'snapshot-request',modelId:'snapshot-model',contextUsage:usage}
await module.sessions.appendMessage(snapshotSession.id,snapshotMessage)
const branchBefore=await invoke('session:messages',snapshotSession.id)
assert.equal(await invoke('session:messages',snapshotSession.id),branchBefore,'real store getter returns stable active branch identity')
await module.sessions.patchMessage(snapshotSession.id,snapshotMessage.id,{contextBreakdown:savedBreakdown})
await module.sessions.patchMessage(snapshotSession.id,snapshotMessage.id,{requestId:snapshotMessage.requestId,modelId:snapshotMessage.modelId,contextUsage:usage,contextBreakdown:undefined,status:'done'},{preserveContextBreakdown:true})
assert.deepEqual(snapshotMessage.contextBreakdown,savedBreakdown,'terminal checkpoint cannot erase independently saved summary')
assert.deepEqual(snapshotMessage.contextUsage,usage,'snapshot persistence and checkpoint keep real usage intact')
await module.sessions.patchMessage(snapshotSession.id,snapshotMessage.id,{requestId:snapshotMessage.requestId,modelId:snapshotMessage.modelId,contextBreakdown:{...savedBreakdown,sampledAt:8}},{preserveContextBreakdown:true})
assert.equal(snapshotMessage.contextBreakdown?.sampledAt,10,'older cloned category cannot replace newer summary')
await module.sessions.patchMessage(snapshotSession.id,snapshotMessage.id,{requestId:snapshotMessage.requestId,modelId:snapshotMessage.modelId,contextBreakdown:{...savedBreakdown,sampledAt:11}},{preserveContextBreakdown:true})
assert.equal(snapshotMessage.contextBreakdown?.sampledAt,11,'newer same-request category still updates')
const instance={}
state.kernel={getContextSnapshotIdentity:()=>({instance,requestId:snapshotMessage.requestId,groupKey:'group',closed:false,draining:false}),getContextBreakdown:async()=>({...savedBreakdown,sampledAt:12})}
const freshInfo=await invoke('session:context-usage',snapshotSession.id) as import('../src/shared/types').ContextUsageInfo
assert.equal(freshInfo.breakdown?.sampledAt,12,'actual IPC adapter persists matching cached summary')
assert.deepEqual(freshInfo.sample,usage)
const fixedState=state.kernel.getContextSnapshotIdentity
state.kernel.getContextBreakdown=async()=>{state.kernel!.getContextSnapshotIdentity=()=>({...fixedState() as object,groupKey:'new-group'});return {...savedBreakdown,sampledAt:13}}
assert.equal((await invoke('session:context-usage',snapshotSession.id) as import('../src/shared/types').ContextUsageInfo).breakdown?.sampledAt,12,'group change during summary is rejected under IPC lock')
state.kernel=undefined
assert.equal((await invoke('session:context-usage',snapshotSession.id) as import('../src/shared/types').ContextUsageInfo).breakdown?.sampledAt,12,'no cached kernel retains last saved categories without starting one')
const storedSnapshot=(await readStoredSessions(sessionsFile)).find(session=>session.id===snapshotSession.id)!.messages.find(message=>message.id===snapshotMessage.id)!
assert.equal(storedSnapshot.contextBreakdown?.sampledAt,12,'summary is durably stored alongside upstream sample')
assert.deepEqual(storedSnapshot.contextUsage,usage)
const inMemory = (await invoke('session:messages', targetSession.id)) as ChatMessage[]
const diskSessions = await readStoredSessions(sessionsFile)
const diskMessages = diskSessions.find((item) => item.id === targetSession.id)?.messages
@@ -194,10 +238,18 @@ async function main(): Promise<void> {
const coldMessages = (await invoke('session:messages', targetSession.id)) as ChatMessage[]
const coldSessions = (await invoke('session:list')) as SessionMeta[]
assert.deepEqual(coldMessages, inMemory, 'a cold module import should recover saved messages from disk')
const coldInfo=await invoke('session:context-usage',snapshotSession.id) as import('../src/shared/types').ContextUsageInfo
assert.equal(coldInfo.breakdown?.sampledAt,12,'cold application reload restores persisted category snapshot')
assert.deepEqual(coldInfo.sample,usage)
assert.equal(coldSessions.length, 12, 'a cold module import should recover all concurrent sessions')
await rm(sessionsFile)
await mkdir(sessionsFile)
state.kernel={getContextSnapshotIdentity:fixedState,getContextBreakdown:async()=>({...savedBreakdown,sampledAt:14})}
const failedSave=await invoke('session:context-usage',snapshotSession.id) as import('../src/shared/types').ContextUsageInfo
assert.equal(failedSave.breakdown?.sampledAt,12,'failed disk write retains last saved snapshot, not fresh unsaved categories')
assert.deepEqual(failedSave.sample,usage)
state.kernel=undefined
await assert.rejects(coldModule.sessions.createSession('transient write failure'),
'a failed atomic rename must reject its caller')
const leftovers = (await readdir(state.userData)).filter((name) => /^\.sessions-.*\.tmp$/.test(name))
@@ -0,0 +1,155 @@
/** Offline host integration: real IPC handlers, turn orchestration and queue, inert external ports. */
import assert from 'node:assert/strict'
import { readFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { resolve, dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const root = process.cwd()
const temp = await mkdtemp(join(tmpdir(), 'ycode-next-permission-host-'))
const real = new Set(['ipc', 'chat-turn-service', 'prompt-queue', 'agent/mode-policy', 'agent/request-settings', 'agent/queued-request-settings'].map(p => resolve('src/main', `${p}.ts`)))
const exportsByModule = new Map()
for (const file of ['src/main/ipc.ts', 'src/main/chat-turn-service.ts']) {
const source = await readFile(file, 'utf8')
for (const match of source.matchAll(/import\s+(?!type\s)([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g)) {
const [, bindings, spec] = match
if (spec.startsWith('node:') || spec.startsWith('@shared/') || spec.startsWith('../shared/')) continue
const key = spec === 'electron' ? spec : resolve(dirname(file), `${spec}.ts`)
if (real.has(key)) continue
const names = exportsByModule.get(key) ?? new Set()
if (bindings.trim().startsWith('{')) {
for (const part of bindings.replace(/[{}]/g, '').split(',')) {
const name = part.trim().split(/\s+/)[0]
if (name && name !== 'type') names.add(name)
}
} else {
const namespace = /\*\s+as\s+(\w+)/.exec(bindings)?.[1]
if (!namespace) throw new Error(`Unsupported import ${bindings}`)
for (const usage of source.matchAll(new RegExp(`\\b${namespace}\\.(\\w+)`, 'g'))) names.add(usage[1])
}
exportsByModule.set(key, names)
}
}
const handlers = new Map()
let context = { agentMode: 'craft', permissionMode: 'default-sandbox', modelId: 'test', cwd: temp }
let messages = []
let activeInput, completeTurn
const policyCalls = [], appendCalls = []
let interactionResponses = 0, attachmentPrepares = 0
const kernel = {
busy: false,
async setPolicy(permission, mode) { policyCalls.push([permission, mode]) },
runTurn(input) { activeInput = input; this.busy = true; return new Promise(resolve => { completeTurn = () => { this.busy = false; resolve({ content: 'done', tools: [], status: 'done', entryId: 'test-entry' }) } }) },
async appendInput(input) { appendCalls.push(input) }
}
const mock = (file, value) => mocks[file === 'electron' ? file : resolve('src/main', `${file}.ts`)] = value
const mocks = {}
mock('electron', { ipcMain: { handle: (name, fn) => handlers.set(name, fn) }, app: { getPath: () => temp }, BrowserWindow: { getAllWindows: () => [] } })
mock('sessions', {
listSessions: async () => [{ id: 's' }], getMessages: async () => structuredClone(messages),
getSessionContext: async () => ({ ...context }), updateSessionContext: async (_id, patch) => (context = { ...context, ...patch }),
getClaudeSession: async () => ({}), startTurn: async (_id, user, assistant, history) => { messages = [...history, user, assistant] },
patchMessage: async (_id, id, patch) => { messages = messages.map(m => m.id === id ? { ...m, ...patch } : m) },
setClaudeFork: async () => {}, clearClaudeFork: async () => {}
})
mock('models', { resolveModel: async () => ({ config: { api: 'anthropic-messages' }, profile: { id: 'test', name: 'Test', protocol: 'anthropic' } }) })
mock('agent/claude/claude-kernel', { getClaudeKernel: async () => kernel, cachedClaudeKernel: () => kernel, releaseClaudeKernel: async () => {} })
mock('attachments', { attachmentPrompt: async () => { attachmentPrepares++; return { text: '', images: [] } }, commitAttachments: async () => {} })
mock('personalization', { getPersonalization: async () => ({}), buildPersonalizationPrompt: () => '' })
mock('memory-settings', { getMemorySettings: async () => ({}) })
mock('projects', { ProjectRepository: class {}, buildProjectPrompt: () => '' })
mock('capabilities/skills', { buildSkillContext: async () => '', listSkills: async () => [] })
mock('interaction-broker', { createInteractionBroker: () => ({}), respondToInteraction: () => { interactionResponses++ } })
mock('message-checkpoint', { createMessageCheckpoint: save => ({ schedule: () => {}, finish: save }) })
mock('automations/host', { createAutomationHost: () => ({ api: {} }) })
mock('update-maintenance', { withRuntimeStart: fn => fn() })
mock('workspaces', { workspaceRoot: async () => temp })
mock('config', { getWorkspaceRoot: async () => temp })
// Uninvoked adapter exports are inert; any accidental use throws instead of reaching real services.
const inert = function () { if (!new.target) throw new Error('Unexpected external adapter invocation') }
for (const [file, names] of exportsByModule) {
mocks[file] ??= {}
for (const name of names) mocks[file][name] ??= inert
}
for (const [file, name] of [['browser-host', 'setBrowserHostEventSink'], ['capabilities/recommended-skills', 'subscribeRecommendedSkillInstall'], ['general-settings', 'updateActiveRuns']]) mocks[resolve('src/main', `${file}.ts`)][name] = () => {}
mocks[resolve('src/main/library/user-state.ts')].libraryUserStatePath = () => temp
mocks[resolve('src/main/context-usage-service.ts')].createContextUsageService = () => ({})
globalThis.__nextPermissionHostMocks = mocks
try {
await build({ stdin: { contents: "export { registerIpc } from './src/main/ipc'; export { createChatTurnService, runTasks } from './src/main/chat-turn-service'; export { createPromptQueue } from './src/main/prompt-queue'", resolveDir: root, loader: 'ts' }, outfile: join(temp, 'host.mjs'), bundle: true, platform: 'node', format: 'esm', logLevel: 'warning', plugins: [{ name: 'offline-ports', setup(b) {
b.onResolve({ filter: /.*/ }, args => {
const key = args.path === 'electron' ? 'electron' : args.path.startsWith('.') ? resolve(args.resolveDir, `${args.path}.ts`) : null
if (exportsByModule.has(key)) return { path: key, namespace: 'offline' }
})
b.onLoad({ filter: /.*/, namespace: 'offline' }, args => ({ contents: [...exportsByModule.get(args.path)].map(name => `export const ${name} = globalThis.__nextPermissionHostMocks[${JSON.stringify(args.path)}][${JSON.stringify(name)}];`).join('\n'), loader: 'js' }))
} }] })
const { registerIpc, createChatTurnService, createPromptQueue } = await import(pathToFileURL(join(temp, 'host.mjs')).href)
registerIpc(() => null)
const update = patch => handlers.get('session:update-context')({}, 's', patch)
const service = createChatTurnService({ projects: {}, sessionRoot: async () => temp, broadcastChat: () => {} })
const first = await service.startTurn({ sessionId: 's', text: 'first' })
await new Promise(resolve => setImmediate(resolve))
assert.equal(kernel.busy, true)
activeInput.emit({ runId: first.runId, type: 'interaction', interaction: { id: 'approval', kind: 'approval', status: 'pending' } })
await update({ permissionMode: 'full-access' })
assert.equal(context.permissionMode, 'full-access')
assert.deepEqual(policyCalls, [['default-sandbox', 'craft']])
assert.equal(interactionResponses, 0)
const { runTasks } = await import(pathToFileURL(join(temp, 'host.mjs')).href)
assert.equal(runTasks.get(first.runId).snapshot().messages.at(-1).interactions[0].status, 'pending')
await assert.rejects(update({ permissionMode: 'invalid' }))
await assert.rejects(update({ agentMode: 'ask' }))
// Resolve the fake interaction directly (not by the preference change), then test steering mismatch.
activeInput.emit({ runId: first.runId, type: 'interaction', interaction: { id: 'approval', kind: 'approval', status: 'completed' } })
const queue = createPromptQueue({ file: join(temp, 'queue.json'), validate: async () => {}, blocked: async () => true, deliver: (p, id) => service.startTurn(p, undefined, id), deliverNow: (p, id) => service.appendToRun(p, id), changed: () => {} })
const queued = await queue.enqueue({ sessionId: 's', text: 'new preference', permissionMode: 'full-access' })
const id = queued.items[0].id
const preparedBefore = attachmentPrepares
await assert.rejects(queue.sendNow('s', id), error => error.submitted === false && /权限/.test(error.message))
assert.equal(attachmentPrepares, preparedBefore)
assert.equal(appendCalls.length, 0)
const preserved = await queue.get('s')
assert.equal(preserved.items[0].id, id)
assert.equal(preserved.items[0].status, 'pending')
assert.equal(preserved.items[0].payload.permissionMode, 'full-access')
assert.equal(preserved.paused, true)
await service.appendToRun({ sessionId: 's', text: 'old captured mode', permissionMode: 'default-sandbox' }, 'old-queued')
assert.equal(appendCalls.length, 1)
assert.deepEqual(policyCalls, [['default-sandbox', 'craft']])
completeTurn(); assert.equal((await first.completion).status, 'done')
const second = await service.startTurn({ sessionId: 's', text: 'next turn' })
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(policyCalls, [['default-sandbox', 'craft'], ['full-access', 'craft']])
completeTurn(); assert.equal((await second.completion).status, 'done')
const capturedOld = await service.startTurn({ sessionId: 's', text: 'captured old queued', permissionMode: 'default-sandbox' }, undefined, 'captured-old')
await new Promise(resolve => setImmediate(resolve))
assert.equal(policyCalls.at(-1)[0], 'default-sandbox')
assert.equal(context.permissionMode, 'full-access', 'old queue must not overwrite next-send preference')
completeTurn(); assert.equal((await capturedOld.completion).status, 'done')
// Exercise the actual registered queue IPC validation path for persisted legacy items.
const ipcTurn = await handlers.get('chat:send')({}, { sessionId: 's', text: 'IPC full-access run' })
await new Promise(resolve => setImmediate(resolve))
assert.equal(policyCalls.at(-1)[0], 'full-access')
const diskQueue = createPromptQueue({ file: join(temp, 'prompt-queues.json'), validate: async () => {}, blocked: async () => true, deliver: async () => { throw new Error('Unexpected automatic delivery') }, changed: () => {} })
const legacy = await diskQueue.enqueue({ sessionId: 's', text: 'legacy missing permission' })
const legacyId = legacy.items[0].id
await assert.rejects(handlers.get('chat:queue:send-now')({}, 's', legacyId), error => error.submitted === false && /权限/.test(error.message))
const legacyPreserved = await handlers.get('chat:queue:get')({}, 's')
assert.equal(legacyPreserved.items[0].status, 'pending')
assert.equal(legacyPreserved.items[0].payload.permissionMode, 'default-sandbox')
const capturedNew = await handlers.get('chat:queue:enqueue')({}, { sessionId: 's', text: 'new captured preference' })
assert.equal(capturedNew.items.at(-1).payload.permissionMode, 'full-access')
await update({ permissionMode: 'default-sandbox' })
const capturedNewest = await handlers.get('chat:queue:enqueue')({}, { sessionId: 's', text: 'newest preference' })
assert.deepEqual(capturedNewest.items.map(item => item.payload.permissionMode), ['default-sandbox', 'full-access', 'default-sandbox'])
assert.equal(policyCalls.at(-1)[0], 'full-access', 'queue and preference changes do not mutate the active policy')
const ipcCompletion = runTasks.get(ipcTurn.runId).promise
completeTurn(); await ipcCompletion
// Queue read shares the repository lock and drains the completion notification.
await handlers.get('chat:queue:get')({}, 's')
console.log('PASS offline real-host permission isolation: pending approval unchanged, no active setPolicy, next turn updated, queued mode retained, sendNow mismatch preserved without preparation effects')
} finally {
delete globalThis.__nextPermissionHostMocks
await rm(temp, { recursive: true, force: true })
}