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
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user