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