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

+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))