Files
ycode/scripts/session-store-regression.ts
T

292 lines
16 KiB
TypeScript

import { modelBridgeHistoryDirectory } from '../src/main/model-bridge/paths'
import assert from 'node:assert/strict'
import { testTerminalHostPlugin } from './test-terminal-host-plugin'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import type { ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types'
interface SessionStoreApi {
deleteSession(sessionId: string): Promise<void>
listSessions(): Promise<SessionMeta[]>
createSession(title?: string): Promise<SessionMeta>
appendMessage(sessionId: string, message: ChatMessage): Promise<void>
patchMessage(sessionId: string, messageId: string, patch: Partial<ChatMessage>, options?: { preserveContextBreakdown?: boolean }): Promise<void>
}
interface IpcModule {
registerIpc(getWindow: () => unknown): void
sessions: SessionStoreApi
}
interface RegressionState {
userData: string
handlers: Map<string, (...args: unknown[]) => unknown>
kernel?: {getContextSnapshotIdentity():unknown;getContextBreakdown():Promise<unknown>}
}
const STATE_KEY = '__YCODE_SESSION_STORE_REGRESSION__'
function tool(id: string, status: ToolCallRecord['status'] = 'running'): ToolCallRecord {
return {
id,
name: 'shell',
args: { command: 'test' },
argsRaw: '{"command":"test"}',
status,
output: `output-${id}`
}
}
function makeMessage(index: number): ChatMessage {
return {
id: `assistant-${index}`,
role: 'assistant',
content: `streaming-${index}`,
tools: [tool(`tool-${index}`)],
parts: [
{ type: 'text', text: `streaming-${index}` },
{ type: 'tool', toolId: `tool-${index}` }
],
createdAt: index,
status: 'streaming'
}
}
async function readStoredSessions(path: string): Promise<Array<SessionMeta & { messages: ChatMessage[] }>> {
const parsed = JSON.parse(await readFile(path, 'utf8')) as {
sessions: Array<SessionMeta & { messages: ChatMessage[] }>
}
assert.ok(Array.isArray(parsed.sessions), 'sessions.json should contain a sessions array')
return parsed.sessions
}
function installHandlers(module: IpcModule, state: RegressionState): void {
module.registerIpc(() => ({ webContents: { send: () => undefined } }))
const invoke = async (channel: string, ...args: unknown[]): Promise<unknown> => {
const handler = state.handlers.get(channel)
if (!handler) throw new Error(`Missing IPC handler: ${channel}`)
return handler({}, ...args)
}
;(state as RegressionState & { invoke: typeof invoke }).invoke = invoke
}
async function main(): Promise<void> {
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-session-store-'))
const state: RegressionState = {
userData: join(tempRoot, 'userData'),
handlers: new Map()
}
const globals = globalThis as typeof globalThis & Record<string, unknown>
const previous = globals[STATE_KEY]
globals[STATE_KEY] = state
try {
const ipcPath = join(process.cwd(), 'src', 'main', 'ipc.ts')
const sessionsPath = join(process.cwd(), 'src', 'main', 'sessions.ts')
const outfile = join(tempRoot, 'session-store.mjs')
await build({
stdin: {
contents: `export { registerIpc } from ${JSON.stringify(ipcPath)};\nexport * as sessions from ${JSON.stringify(sessionsPath)};`,
resolveDir: process.cwd(),
sourcefile: 'session-store-regression-entry.ts',
loader: 'ts'
},
outfile,
bundle: true,
external: ['node-pty'],
platform: 'node',
format: 'esm',
banner: { js: `import { createRequire as createTestRequire } from 'node:module'; const require = createTestRequire(import.meta.url);` },
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) {
pluginBuild.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'mock-electron' }))
pluginBuild.onLoad({ filter: /.*/, namespace: 'mock-electron' }, () => ({
loader: 'js',
contents: `
const state = globalThis.${STATE_KEY};
export const app = {
getPath: (name) => name === 'userData' ? state.userData : state.userData,
getName: () => 'YCode',
getVersion: () => '0.1.0'
};
export const safeStorage = {
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(value),
decryptString: (value) => value.toString()
};
export const ipcMain = {
handle: (name, handler) => state.handlers.set(name, handler)
};
export const webContents = { getAllWebContents: () => [] };
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }), showSaveDialog: async () => ({ canceled: true }) };
export const shell = { openPath: async () => '' };
export const powerSaveBlocker = { start: () => 1, stop: () => undefined };
export class BrowserWindow {}
export class WebContentsView {}
`
}))
}
}
]
})
const invokeState = state as RegressionState & {
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>
}
const bundleUrl = pathToFileURL(outfile).href
const module = (await import(bundleUrl)) as IpcModule
installHandlers(module, state)
const invoke = invokeState.invoke
const sessionsFile = join(state.userData, 'sessions.json')
assert.equal(await import('node:fs/promises').then(({ stat }) => stat(state.userData).then(() => true, () => false)), false,
'the test should begin without a user-data directory')
const created = await Promise.all(
Array.from({ length: 12 }, (_, index) => invoke('session:create', `Concurrent ${index}`) as Promise<SessionMeta>)
)
const listed = (await invoke('session:list')) as SessionMeta[]
assert.equal(created.length, 12)
assert.equal(new Set(created.map((item) => item.id)).size, 12)
assert.equal(listed.length, 12, 'concurrent first creates should all survive in memory')
assert.deepEqual(new Set(listed.map((item) => item.title)), new Set(created.map((item) => item.title)))
assert.ok(await import('node:fs/promises').then(({ stat }) => stat(state.userData).then(() => true, () => false)),
'the first persist should create the user-data directory')
assert.equal((await readStoredSessions(sessionsFile)).length, 12,
'concurrent first creates should all survive on disk')
const targetSession = created[0]
const messages = Array.from({ length: 10 }, (_, index) => makeMessage(index))
await Promise.all(messages.map((message) => module.sessions.appendMessage(targetSession.id, message)))
await Promise.all(messages.map((message, index) => {
const terminalStatus = index % 2 === 0 ? 'done' as const : 'cancelled' as const
const toolStatus = index % 2 === 0 ? 'ok' as const : 'cancelled' as const
return module.sessions.patchMessage(targetSession.id, message.id, {
content: `terminal-${index}`,
parts: [
{ type: 'text', text: `terminal-${index}` },
{ type: 'tool', toolId: `tool-${index}` }
],
tools: [tool(`tool-${index}`, toolStatus)],
status: terminalStatus
})
}))
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
assert.ok(diskMessages)
assert.deepEqual(diskMessages, inMemory,
'the latest atomic JSON snapshot should match the session:messages IPC result')
assert.equal(inMemory.length, 10)
for (const [index, message] of inMemory.entries()) {
assert.equal(message.status, index % 2 === 0 ? 'done' : 'cancelled')
assert.equal(message.tools?.[0].status, index % 2 === 0 ? 'ok' : 'cancelled')
assert.equal(message.tools?.[0].output, `output-tool-${index}`)
}
const coldModule = (await import(`${bundleUrl}?cold=${Date.now()}`)) as IpcModule
installHandlers(coldModule, state)
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))
assert.deepEqual(leftovers, [], 'a failed write should remove its temporary file')
await rm(sessionsFile, { recursive: true })
const recovered = await coldModule.sessions.createSession('queue recovered')
assert.equal(recovered.title, 'queue recovered')
const recoveredDisk = await readStoredSessions(sessionsFile)
assert.ok(recoveredDisk.some((item) => item.id === recovered.id),
'a later write should recover after a transient failure')
const afterRecovery = (await invoke('session:messages', targetSession.id)) as ChatMessage[]
assert.deepEqual(afterRecovery, inMemory,
'session:messages should remain consistent after the write queue recovers')
const deletedHistory = modelBridgeHistoryDirectory(state.userData, recovered.id)
const retainedHistory = modelBridgeHistoryDirectory(state.userData, targetSession.id)
await mkdir(deletedHistory, { recursive: true })
await mkdir(retainedHistory, { recursive: true })
await writeFile(join(deletedHistory, 'route.json'), '{}')
await writeFile(join(retainedHistory, 'route.json'), '{}')
await coldModule.sessions.deleteSession(recovered.id)
await assert.rejects(readFile(join(deletedHistory, 'route.json')), /ENOENT/)
assert.equal(await readFile(join(retainedHistory, 'route.json'), 'utf8'), '{}', 'deleting one session preserves another bridge history')
assert.ok(!modelBridgeHistoryDirectory(state.userData, '../escape').includes('..'), 'session identifiers are hashed, not path segments')
console.log('session-store-regression: all checks passed')
} finally {
if (previous === undefined) delete globals[STATE_KEY]
else globals[STATE_KEY] = previous
await rm(tempRoot, { recursive: true, force: true })
}
}
void main().catch((error: unknown) => {
console.error('session-store-regression failed:', error)
process.exitCode = 1
})