diff --git a/scripts/chat-store-regression.ts b/scripts/chat-store-regression.ts index 9295105..d7eef4e 100644 --- a/scripts/chat-store-regression.ts +++ b/scripts/chat-store-regression.ts @@ -695,6 +695,7 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise { const result = sendResult('run-order') const response = deferred() let sendCalls = 0 + const enteredSend = deferred() 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 { 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 { 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 | undefined + try { + await Promise.race([enteredSend.promise, new Promise((_, 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') diff --git a/scripts/context-usage-regression.ts b/scripts/context-usage-regression.ts index 2dd6273..ea9aa80 100644 --- a/scripts/context-usage-regression.ts +++ b/scripts/context-usage-regression.ts @@ -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 = {}) { + 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') diff --git a/scripts/permission-next-send-render-regression.tsx b/scripts/permission-next-send-render-regression.tsx new file mode 100644 index 0000000..92490e1 --- /dev/null +++ b/scripts/permission-next-send-render-regression.tsx @@ -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( { 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') diff --git a/scripts/prompt-queue-adversarial-regression.ts b/scripts/prompt-queue-adversarial-regression.ts index 87edbea..ae55f98 100644 --- a/scripts/prompt-queue-adversarial-regression.ts +++ b/scripts/prompt-queue-adversarial-regression.ts @@ -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 { diff --git a/scripts/request-settings-regression.ts b/scripts/request-settings-regression.ts index 6563797..4032b99 100644 --- a/scripts/request-settings-regression.ts +++ b/scripts/request-settings-regression.ts @@ -341,7 +341,7 @@ async function testLateResultAndEventStayInOriginalSession(): Promise { } async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise { - resetStore([], null, [], { modelId: 'draft-a', thinkingLevel: 'low' }) + resetStore([], null, [], { modelId: 'draft-a', thinkingLevel: 'low', permissionMode: 'default-sandbox' }) const backend = installFakeBackend([], {}) const createGate = deferred() const updateGate = deferred() @@ -357,26 +357,28 @@ async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise { 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 { 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 { + 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() + 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 { + 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)) diff --git a/scripts/session-store-regression.ts b/scripts/session-store-regression.ts index 4cea951..bc6b63f 100644 --- a/scripts/session-store-regression.ts +++ b/scripts/session-store-regression.ts @@ -11,7 +11,7 @@ interface SessionStoreApi { listSessions(): Promise createSession(title?: string): Promise appendMessage(sessionId: string, message: ChatMessage): Promise - patchMessage(sessionId: string, messageId: string, patch: Partial): Promise + patchMessage(sessionId: string, messageId: string, patch: Partial, options?: { preserveContextBreakdown?: boolean }): Promise } interface IpcModule { @@ -22,6 +22,7 @@ interface IpcModule { interface RegressionState { userData: string handlers: Map unknown> + kernel?: {getContextSnapshotIdentity():unknown;getContextBreakdown():Promise} } const STATE_KEY = '__YCODE_SESSION_STORE_REGRESSION__' @@ -100,6 +101,20 @@ async function main(): Promise { 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 { }) })) + 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 { 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)) diff --git a/scripts/test-next-permission-host-isolation.mjs b/scripts/test-next-permission-host-isolation.mjs new file mode 100644 index 0000000..8cc4e0a --- /dev/null +++ b/scripts/test-next-permission-host-isolation.mjs @@ -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 }) +} diff --git a/src/main/agent/claude/claude-kernel.ts b/src/main/agent/claude/claude-kernel.ts index 5591406..7ead5b9 100644 --- a/src/main/agent/claude/claude-kernel.ts +++ b/src/main/agent/claude/claude-kernel.ts @@ -1137,6 +1137,12 @@ export class ClaudeKernel { } finally { if (timer) clearTimeout(timer) } } + /** Opaque read-only identity for committing a summary after awaiting the session lock. */ + getContextSnapshotIdentity() { + return { instance: this.instance, requestId: this.lastRequestId, groupKey: this.lastGroupKey, + closed: this.closed, draining: this.draining } + } + async getContextBreakdown(): Promise { const requestId = this.lastRequestId const groupKey = this.lastGroupKey @@ -1146,13 +1152,16 @@ export class ClaudeKernel { const result = await this.getContextSummary() if (!result || !requestId || requestId !== this.lastRequestId || groupKey !== this.lastGroupKey || instance !== this.instance) return undefined const counts: ContextUsageBreakdown['counts'] = { system: 0, tools: 0, conversation: 0, mcp: 0, skills: 0, other: 0 } + let observedCategories = 0 for (const category of result.categories) { if (category.kind !== 'used' || !Number.isFinite(category.tokens) || category.tokens < 0) continue + observedCategories++ const id = category.id const key = id === 'system_prompt' || id === 'memory_files' ? 'system' : id === 'system_tools' ? 'tools' : id === 'messages' ? 'conversation' : id === 'mcp_tools' || id === 'mcp_instructions' ? 'mcp' : id === 'skills' ? 'skills' : 'other' counts[key] += category.tokens } + if (!observedCategories) return undefined const contextWindow = this.options.model.profile.contextWindowTokens ?? this.options.model.profile.maxInputTokens return { requestId, sequence: sampledAt, sampledAt, modelId: this.options.model.profile.id, contextWindow, method: 'native-summary', scope: 'prepared-request', counts, diff --git a/src/main/agent/queued-request-settings.ts b/src/main/agent/queued-request-settings.ts index 0fd50ed..4a7aae1 100644 --- a/src/main/agent/queued-request-settings.ts +++ b/src/main/agent/queued-request-settings.ts @@ -11,14 +11,12 @@ export interface QueuedRequestSettings { persistSessionPermission: boolean } -/** Direct sends may seed session policy; queued delivery always follows current session policy. */ +/** Submission captures policy. Delivery must neither adopt nor overwrite a newer preference. */ export function resolveQueuedRequestSettings(input: QueuedRequestSettingsInput): QueuedRequestSettings { - const permissionMode = input.queued - ? input.sessionPermissionMode ?? 'default-sandbox' - : input.payloadPermissionMode ?? input.sessionPermissionMode ?? 'default-sandbox' + const permissionMode = input.payloadPermissionMode ?? (input.queued ? 'default-sandbox' : input.sessionPermissionMode) ?? 'default-sandbox' if (permissionMode !== 'default-sandbox' && permissionMode !== 'full-access') throw new Error('权限模式无效') return { permissionMode, - persistSessionPermission: !input.queued && input.sessionPermissionMode !== permissionMode + persistSessionPermission: !input.queued && input.sessionPermissionMode === undefined } } diff --git a/src/main/agent/request-settings.ts b/src/main/agent/request-settings.ts index 7757b86..28827e7 100644 --- a/src/main/agent/request-settings.ts +++ b/src/main/agent/request-settings.ts @@ -4,8 +4,8 @@ import { thinkingLevel } from './mode-policy' /** During a run only future-request settings may change, never active policy. */ export function nextRequestSettings(patch: SessionContext): SessionContext { if (!patch || typeof patch !== 'object' || Array.isArray(patch) || - Object.keys(patch).some(key => key !== 'modelId' && key !== 'thinkingLevel')) { - throw new Error('运行期间只能修改下次发送的模型和思考强度') + Object.keys(patch).some(key => key !== 'modelId' && key !== 'thinkingLevel' && key !== 'permissionMode')) { + throw new Error('运行期间只能修改下次发送的模型、思考强度和权限') } const result: SessionContext = {} if (patch.modelId !== undefined) { @@ -13,5 +13,9 @@ export function nextRequestSettings(patch: SessionContext): SessionContext { result.modelId = patch.modelId } if (patch.thinkingLevel !== undefined) result.thinkingLevel = thinkingLevel(patch.thinkingLevel) + if (patch.permissionMode !== undefined) { + if (patch.permissionMode !== 'default-sandbox' && patch.permissionMode !== 'full-access') throw new Error('权限模式无效') + result.permissionMode = patch.permissionMode + } return result } diff --git a/src/main/chat-turn-service.ts b/src/main/chat-turn-service.ts index 46f1737..e06ca4c 100644 --- a/src/main/chat-turn-service.ts +++ b/src/main/chat-turn-service.ts @@ -239,8 +239,8 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot content: message.content, tools: message.tools, parts: message.parts, interactions: message.interactions, status: message.status, turnStartedAt: message.turnStartedAt, turnDurationMs: message.turnDurationMs, turnFinishedAt: message.turnFinishedAt, contextUsage: message.contextUsage, contextBreakdown: message.contextBreakdown, error: message.error, - claudeEntryId: message.claudeEntryId - }), + claudeEntryId: message.claudeEntryId, requestId: message.requestId, modelId: message.modelId + }, { preserveContextBreakdown: true }), (error) => console.error('会话增量保存失败,将在回合结束时重试', error) ) let checkpoint = createCheckpoint() @@ -291,6 +291,8 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot if (controller.signal.aborted || !kernel.busy || payload.command || input.command || input.action) notSubmitted('当前运行不能接收引导消息') const pendingInteraction = () => assistant.interactions?.some(item => ['pending', 'responding', 'error'].includes(item.status)) if (pendingInteraction()) notSubmitted('请先处理当前问答或授权') + // Live steering shares this turn's immutable policy; different policy waits for a new turn. + if ((input.permissionMode ?? 'default-sandbox') !== permissionMode) notSubmitted('权限已调整;请等待下一回合发送,不能更改当前运行权限') if (preparedAppends.has(id) || (await sessions.getMessages(sessionId)).some(message => message.id === id)) notSubmitted('追加消息已提交,不能重复执行') const files = await Promise.all((input.attachments ?? []).map(item => getAttachment(item.id))) const attachment = await attachmentPrompt(files) diff --git a/src/main/context-usage-service.ts b/src/main/context-usage-service.ts index 7f7bb65..4b162b0 100644 --- a/src/main/context-usage-service.ts +++ b/src/main/context-usage-service.ts @@ -1,33 +1,54 @@ -import type { ChatMessage, ContextUsageBreakdown, ContextUsageInfo, ContextUsageSnapshot } from '../shared/types' -import { contextInfoFromMessages } from '../shared/context-usage' +import type { ChatMessage, ContextUsageBreakdown, ContextUsageInfo } from '../shared/types' +import { contextInfoFromMessages, normalizeContextUsageBreakdown } from '../shared/context-usage' +export interface ContextSnapshotGuard { + branch: string + assistantId: string + requestId: string + modelId: string + hostIdentity?: unknown +} export interface ContextUsageDependencies { messages(id:string):Promise modelId(id:string):Promise running(id:string):boolean summary?(id:string):Promise - /** The host rechecks identity inside its existing session mutation lock. */ - persistIfCurrent(id:string,assistantId:string,requestId:string|undefined,sample:ContextUsageSnapshot):Promise + captureIdentity?(id:string):Promise + /** Recheck the branch, request, model and host identity inside the session mutation lock. */ + persistIfCurrent(id:string,guard:ContextSnapshotGuard,breakdown:ContextUsageBreakdown):Promise } const latestAssistant=(messages:ChatMessage[])=>messages.findLast(message=>message.role==='assistant') +export const contextBranchIdentity=(messages:ChatMessage[]):string=>JSON.stringify(messages.map(({id,role,requestId,modelId})=>[id,role,requestId,modelId])) +export function contextSnapshotMatches(messages:ChatMessage[],guard:ContextSnapshotGuard,summary:ContextUsageBreakdown):boolean { + const assistant=latestAssistant(messages) + return contextBranchIdentity(messages)===guard.branch&&assistant?.id===guard.assistantId + &&assistant.requestId===guard.requestId&&assistant.modelId===guard.modelId + &&summary.requestId===guard.requestId&&summary.modelId===guard.modelId +} export function createContextUsageService(deps:ContextUsageDependencies):{get(id:string):Promise} { return {async get(id) { let messages=await deps.messages(id) - let summary:ContextUsageBreakdown|undefined - try { summary=await deps.summary?.(id) } catch { /* Retain real stream samples on query failure. */ } - if(summary)messages=await deps.messages(id) + const original=latestAssistant(messages) + // Copy scalar identity before any await: the store's array and messages are mutable. + const guard:ContextSnapshotGuard|undefined=original?.requestId&&original.modelId?{ + branch:contextBranchIdentity(messages),assistantId:original.id,requestId:original.requestId,modelId:original.modelId + }:undefined + if(guard) { + try { + guard.hostIdentity=await deps.captureIdentity?.(id) + const summary=normalizeContextUsageBreakdown(await deps.summary?.(id)) + if(summary)await deps.persistIfCurrent(id,guard,summary) + } catch { /* Retain the last persisted snapshot on query or write failure. */ } + messages=await deps.messages(id) + } const assistant=latestAssistant(messages) - let info=contextInfoFromMessages(messages) - if(summary&&summary.requestId===assistant?.requestId&&summary.modelId===assistant?.modelId)info={...info,breakdown:summary,status:info.sample?'latest':'unavailable',reason:'分类来自内核 summary 的本地估计'} + const info=contextInfoFromMessages(messages) const selectedModelId=await deps.modelId(id) const running=deps.running(id) - const decorate=(value:ContextUsageInfo):ContextUsageInfo=>{ - const model=value.sample?.modelId??value.breakdown?.modelId??assistant?.modelId - return model&&selectedModelId&&model!==selectedModelId&&!running&&assistant?.status!=='streaming' - ?{...value,status:'previous',reason:'来自切换模型前的最近请求'}:value - } + const model=info.sample?.modelId??info.breakdown?.modelId??assistant?.modelId if(!assistant)return {status:'unavailable',reason:'发送消息后可查看用量'} - return decorate(info) + return model&&selectedModelId&&model!==selectedModelId&&!running&&assistant.status!=='streaming' + ?{...info,status:'previous',reason:'来自切换模型前的最近请求'}:info }} } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 92fc0ce..26eef17 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -48,7 +48,7 @@ import { FileArtifactIndexRepository } from './library/artifact-repository' import { ArtifactIndexService } from './library/artifact-service' import { LibraryService } from './library/service' import { JsonLibraryUserStateRepository, libraryUserStatePath } from './library/user-state' -import { createContextUsageService } from './context-usage-service' +import { createContextUsageService, contextSnapshotMatches } from './context-usage-service' import { nextRequestSettings } from './agent/request-settings' import { createPromptQueue } from './prompt-queue' import * as skills from './capabilities/skills' @@ -57,7 +57,6 @@ import { getRecommendedSkills, installRecommendedSkill, cancelRecommendedSkillIn initializeRecommendedSkillInstalls, stopRecommendedSkillInstalls } from './capabilities/recommended-skills' import type { RecommendedSkillInstallOptions } from '@shared/capabilities' import * as mcp from './capabilities/mcp' -import { contextInfoFromMessages } from '@shared/context-usage' import { assertRuntimeAvailable, withRuntimeStart } from './update-maintenance' let automationHost: ReturnType | undefined @@ -106,6 +105,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { file: join(app.getPath('userData'), 'prompt-queues.json'), validate: async payload => { await assertQueueSession(payload.sessionId) + // Legacy queued messages have no captured policy: fail safe, never borrow today's preference. + payload.permissionMode ??= 'default-sandbox' + if (payload.permissionMode !== 'default-sandbox' && payload.permissionMode !== 'full-access') throw new Error('权限模式无效') if (payload.command || payload.action || payload.fromMessageId) throw new Error('命令与重新发送不能加入追加队列') if (typeof payload.text !== 'string' || (!payload.text.trim() && !payload.attachments?.length && !payload.resources?.length && !payload.quotes?.length && !payload.skillIds?.length && !payload.mcpServerIds?.length)) throw new Error('消息为空') if (await hasPendingInteraction(payload.sessionId)) throw new Error('请先处理当前问答或授权') @@ -128,7 +130,12 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { changed: snapshot => broadcastChat('chat:queue:changed', snapshot) }) ipcMain.handle('chat:queue:get', async (_event, id: string) => { await assertQueueSession(id); return promptQueue.get(id) }) - ipcMain.handle('chat:queue:enqueue', (_event, payload: SendPayload) => withRuntimeStart(() => promptQueue.enqueue(payload))) + ipcMain.handle('chat:queue:enqueue', (_event, payload: SendPayload) => withRuntimeStart(async () => { + await assertQueueSession(payload?.sessionId) + // Capture once at submission, including callers other than the renderer. + const permissionMode = payload.permissionMode ?? (await sessions.getSessionContext(payload.sessionId)).permissionMode ?? 'default-sandbox' + return promptQueue.enqueue({ ...payload, permissionMode }) + })) ipcMain.handle('chat:queue:restore-edit', async (_event, sessionId: string, id: string) => { await assertQueueSession(sessionId) if (typeof id !== 'string') throw new Error('编辑取回标识无效') @@ -536,6 +543,8 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { ipcMain.handle('session:update-context', (_event, id: string, patch: SessionContext) => withChatSessionLock(id, async () => { if ([...runTasks.values()].some((run) => run.sessionId === id)) { const next = nextRequestSettings(patch) + const previous = await sessions.getSessionContext(id) + if (next.permissionMode !== undefined && agentMode(previous.agentMode) !== 'craft') throw new Error('计划和仅问答模式下不能修改权限') if (next.modelId && !(await models.resolveModel(next.modelId))) throw new Error('模型不存在') return sessions.updateSessionContext(id, next) } @@ -555,6 +564,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { if (patch.modelId !== undefined) context.modelId = patch.modelId if (patch.thinkingLevel !== undefined) context.thinkingLevel = thinkingLevel(patch.thinkingLevel) if (patch.permissionMode !== undefined) { + if (agentMode(context.agentMode) !== 'craft') throw new Error('计划和仅问答模式下不能修改权限') if (patch.permissionMode !== 'default-sandbox' && patch.permissionMode !== 'full-access') throw new Error('权限模式无效') context.permissionMode = patch.permissionMode } @@ -562,15 +572,35 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { return sessions.updateSessionContext(id, context) })) ipcMain.handle('session:messages', (_event, sessionId: string) => sessions.getMessages(sessionId)) + const captureContextIdentity = async (id: string) => { + const kernel = cachedClaudeKernel(id) + return { branch: await sessions.getMessages(id), kernel, kernelState: kernel?.getContextSnapshotIdentity(), + fork: JSON.stringify(await sessions.getClaudeSession(id)), config: JSON.stringify(await sessions.getSessionContext(id)) } + } const contextUsageService=createContextUsageService({messages:sessions.getMessages, summary: async id => cachedClaudeKernel(id)?.getContextBreakdown(), + captureIdentity: id => withChatSessionLock(id, () => captureContextIdentity(id)), modelId:async id=>(await sessions.getSessionContext(id)).modelId, running:id=>[...runTasks.values()].some(run=>run.sessionId===id), - persistIfCurrent:(id,assistantId,requestId,sample)=>withChatSessionLock(id,async()=>{ - if([...runTasks.values()].some(run=>run.sessionId===id))return - const latest=(await sessions.getMessages(id)).findLast(message=>message.role==='assistant') - if(latest?.id===assistantId&&latest.requestId===requestId&&!contextInfoFromMessages([latest]).sample) - await sessions.patchMessage(id,assistantId,{contextUsage:sample},{touchSession:false}) + persistIfCurrent:(id,guard,breakdown)=>withChatSessionLock(id,async()=>{ + const before=guard.hostIdentity as Awaited> | undefined + if(!before)return + const now=await captureContextIdentity(id) + if(before.branch!==now.branch||before.kernel!==now.kernel||before.fork!==now.fork||before.config!==now.config)return + const oldState=before.kernelState, newState=now.kernelState + if(!oldState||!newState||oldState.instance!==newState.instance||oldState.requestId!==newState.requestId + ||oldState.groupKey!==newState.groupKey||newState.closed||newState.draining)return + if(!contextSnapshotMatches(now.branch,guard,breakdown))return + const latest=now.branch.findLast(message=>message.role==='assistant')! + if(latest.contextBreakdown&&latest.contextBreakdown.sampledAt>=breakdown.sampledAt)return + // Independent categories augment the real upstream usage; never synthesize or replace it. + const previousBreakdown=latest.contextBreakdown + try { await sessions.patchMessage(id,guard.assistantId,{contextBreakdown:breakdown},{touchSession:false}) } + catch(error) { + // A failed disk write is not a freshly saved snapshot; leave upstream usage untouched. + if(latest.contextBreakdown===breakdown)latest.contextBreakdown=previousBreakdown + throw error + } })}) ipcMain.handle('session:context-usage',(_event,id:string)=>contextUsageService.get(id)) ipcMain.handle('session:rename', (_event, sessionId: string, title: string) => diff --git a/src/main/sessions.ts b/src/main/sessions.ts index ba6999d..e162897 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -203,13 +203,23 @@ export async function patchMessage( sessionId: string, messageId: string, patch: Partial, - options: { touchSession?: boolean } = {} + options: { touchSession?: boolean; preserveContextBreakdown?: boolean } = {} ): Promise { const store = await load() const session = store.sessions.find((s) => s.id === sessionId) if (!session) return const message = session.messages.find((m) => m.id === messageId) if (!message) return + // Query snapshots arrive independently of streaming checkpoint clones. Merge inside + // this synchronous mutation, after load, so a late checkpoint cannot erase them. + const saved = message.contextBreakdown + const incoming = patch.contextBreakdown + if (options.preserveContextBreakdown && saved && message.role === 'assistant' + && patch.requestId === message.requestId && patch.modelId === message.modelId + && saved.requestId === message.requestId && saved.modelId === message.modelId + && (!incoming || incoming.requestId !== saved.requestId || incoming.modelId !== saved.modelId || incoming.sampledAt <= saved.sampledAt)) { + patch = { ...patch, contextBreakdown: saved } + } Object.assign(message, patch) if(options.touchSession!==false)session.updatedAt = Date.now() await persist() diff --git a/src/renderer/src/components/ChatPane.tsx b/src/renderer/src/components/ChatPane.tsx index 6241275..fdcdd87 100644 --- a/src/renderer/src/components/ChatPane.tsx +++ b/src/renderer/src/components/ChatPane.tsx @@ -723,13 +723,16 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, speech={{ transcribe: ({ wavBase64, mimeType }) => window.ycode.speech.transcribe({ audioBase64: wavBase64, mimeType }) }} onTranscript={(text) => setDraft((current) => `${current}${current ? ' ' : ''}${text}`)} permissionPlacement="toolbar" - permissionSlot={ { await useChatStore.getState().selectPermissionMode(mode) try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ } }} - onError={(message) => useChatStore.setState({ error: message })} />} + onError={(message) => { + const state = useChatStore.getState() + if ((state.currentSessionId ?? `new-${state.draftId}`) === draftKey) useChatStore.setState({ error: message }) + }} />} onSend={submit} sending={sending} onStop={running ? () => { const state = useChatStore.getState() if (state.currentSessionId !== currentId || state.running?.runId !== running.runId || hidden) return Promise.reject(new Error('运行所属会话已切换')) diff --git a/src/renderer/src/stores/chat.ts b/src/renderer/src/stores/chat.ts index 1d6c21e..65f25dd 100644 --- a/src/renderer/src/stores/chat.ts +++ b/src/renderer/src/stores/chat.ts @@ -128,6 +128,7 @@ let expertSelectionVersion = 0 let expertWrites: Promise = Promise.resolve() let requestSettingsWrites: Promise = Promise.resolve() // Serialization is global, but a failed/pending setting only gates its own owner. +const permissionSelections = new Map>() const requestSettingsByOwner = new Map>() const requestSettingErrors = new Map>() function trackRequestSettings(owner: string, write: Promise, keys: readonly (keyof SessionContext)[]): void { @@ -355,10 +356,14 @@ export const useChatStore = create((set, get) => { await saveRequestSettings({ modelId: id }) }, async selectPermissionMode(permissionMode) { - if (get().loading || get().loadError || get().running || get().sending) throw new Error('当前不能修改权限') + if (get().loading || get().loadError) throw new Error('当前不能修改权限') + if (permissionMode !== 'default-sandbox' && permissionMode !== 'full-access') throw new Error('权限模式无效') const mode = (get().currentSessionId ? get().sessions.find(item => item.id === get().currentSessionId) : get().draftContext)?.agentMode if (mode && mode !== 'craft') throw new Error('计划和仅问答模式下不能修改权限') - await saveRequestSettings({ permissionMode }) + const owner = get().currentSessionId ?? `new-${get().draftId}` + const saved = saveRequestSettings({ permissionMode }).then(() => permissionMode) + permissionSelections.set(owner, saved) + try { await saved } finally { if (permissionSelections.get(owner) === saved) permissionSelections.delete(owner) } }, async selectExpert(id) { await get().summonExpert(id) @@ -435,11 +440,16 @@ export const useChatStore = create((set, get) => { const patch: SessionContext = {} if (latest.modelId !== context?.modelId && latest.modelId !== undefined) patch.modelId = latest.modelId if (latest.thinkingLevel !== context?.thinkingLevel && latest.thinkingLevel !== undefined) patch.thinkingLevel = latest.thinkingLevel + if (latest.permissionMode !== context?.permissionMode && latest.permissionMode !== undefined) patch.permissionMode = latest.permissionMode if (Object.keys(patch).length) { const id = created.id const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(id, patch)) trackRequestSettings(id, write, Object.keys(patch) as (keyof SessionContext)[]) - created = { ...created, ...patch } + const { permissionMode, ...otherSettings } = patch + created = { ...created, ...otherSettings } + if (permissionMode !== undefined) void write.then(() => { + set(state => ({ sessions: state.sessions.map(item => item.id === id ? { ...item, permissionMode } : item) })) + }).catch(() => {}) } } void window.ycode.chat.setSessionVisible(created.id) @@ -521,6 +531,10 @@ export const useChatStore = create((set, get) => { void modelWrite?.catch(() => {}) // Read the saved choice at submission time; a React effect is not authoritative. const savedPermission = context?.permissionMode ?? options.permissionMode ?? 'default-sandbox' + // Capture only choices made before this submission, including an in-flight save. + // Later toggles must not rewrite an already submitted/queued message. + const capturedPermission = permissionSelections.get(get().currentSessionId ?? `new-${get().draftId}`) ?? Promise.resolve(savedPermission) + void capturedPermission.catch(() => {}) options = { ...options, permissionMode: savedPermission } if (options.userContent) options = { ...options, userContent: trimUserContent(options.userContent) } if (!trimmed && !options.attachments?.length && !options.quotes?.length && !options.resources?.length && !options.skillIds?.length && !options.mcpServerIds?.length && !options.command) return @@ -550,7 +564,7 @@ export const useChatStore = create((set, get) => { if (options.action || options.command) throw new Error('运行期间不能重新发送或压缩上下文') if (modelWrite) await modelWrite await settleRequestModel(sessionId) - await window.ycode.chat.queue.enqueue({ ...options, permissionMode: get().sessions.find(item => item.id === sessionId)?.permissionMode ?? savedPermission, sessionId, text: trimmed }) + await window.ycode.chat.queue.enqueue({ ...options, permissionMode: await capturedPermission, sessionId, text: trimmed }) return } if (!sessionId || get().sendingSessions.includes(sessionId)) throw new Error('消息正在提交,请稍后重试') @@ -559,7 +573,7 @@ export const useChatStore = create((set, get) => { if (options.action || options.command) throw new Error('请先处理待发送队列') if (modelWrite) await modelWrite await settleRequestModel(sessionId) - await window.ycode.chat.queue.enqueue({ ...options, permissionMode: get().sessions.find(item => item.id === sessionId)?.permissionMode ?? savedPermission, sessionId, text: trimmed }) + await window.ycode.chat.queue.enqueue({ ...options, permissionMode: await capturedPermission, sessionId, text: trimmed }) return } const owner = sessionId @@ -569,7 +583,7 @@ export const useChatStore = create((set, get) => { try { if (modelWrite) await modelWrite await settleRequestModel(owner) - const result = await window.ycode.chat.send({ ...options, permissionMode: get().sessions.find(item => item.id === owner)?.permissionMode ?? savedPermission, sessionId: owner, text: trimmed }) + const result = await window.ycode.chat.send({ ...options, permissionMode: await capturedPermission, sessionId: owner, text: trimmed }) const placeholder: ChatMessage = { id: result.assistantMessageId, requestId: result.runId, role: 'assistant', content: '', tools: [], parts: [], modelId: result.modelId, modelName: result.modelName, assistantDisplay: result.assistantDisplay, createdAt: Date.now(), diff --git a/src/renderer/src/workbuddy/composer/context-info/ComposerContextInfo.tsx b/src/renderer/src/workbuddy/composer/context-info/ComposerContextInfo.tsx index c15a11f..f003152 100644 --- a/src/renderer/src/workbuddy/composer/context-info/ComposerContextInfo.tsx +++ b/src/renderer/src/workbuddy/composer/context-info/ComposerContextInfo.tsx @@ -17,7 +17,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { GitBranchInfo, ContextUsageInfo, ContextUsageCategory } from '@shared/types' -import { contextInfoFromMessages } from '@shared/context-usage' +import { contextInfoFromMessages, contextBreakdownMethodLabel } from '@shared/context-usage' import { IconClose } from '../../../components/icons' import { useChatStore } from '../../../stores/chat' import { BranchCopyIcon, BranchGlyphIcon } from './icons' @@ -238,7 +238,7 @@ export function ContextUsageRing({ sessionId, hasMessages, running }: ContextUsa
{label} - {breakdown?`≈${formatContextTokenCount(breakdown.counts[category])}`:'—'} + {breakdown?`≈${formatContextTokenCount(breakdown.counts[category])}`:'—'}
))} @@ -246,7 +246,7 @@ export function ContextUsageRing({ sessionId, hasMessages, running }: ContextUsa {sourceLabel}{sampledAt?` · ${new Date(sampledAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`:''} - {breakdown?
分类为本地估计{breakdown.partial?',未包含部分输入':''},与上游总量可能不同
:
此历史请求未保存分类明细
} + {breakdown?
分类快照 · {new Date(breakdown.sampledAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})} · {contextBreakdownMethodLabel(breakdown.method)}{breakdown.partial?',未包含部分输入':''},与上游总量可能不同
:
此历史请求未保存分类明细
} ) : null} diff --git a/src/renderer/src/workbuddy/composer/permission/SourcePermissionControl.tsx b/src/renderer/src/workbuddy/composer/permission/SourcePermissionControl.tsx index d17cbbe..e5774b4 100644 --- a/src/renderer/src/workbuddy/composer/permission/SourcePermissionControl.tsx +++ b/src/renderer/src/workbuddy/composer/permission/SourcePermissionControl.tsx @@ -18,6 +18,7 @@ const CheckGlyph = SelectCheckGlyph as ComponentType> interface Props { mode: PermissionMode + nextSend?: boolean disabled?: boolean disabledReason?: string labels?: Partial> @@ -30,7 +31,7 @@ interface Props { * The source panel has one full-access switch, not a two-option permission list. * Its checkbox acknowledges risk; it does not change the global confirmation preference. */ -export default function SourcePermissionControl({ mode, disabled, disabledReason, labels, confirmFullAccessOnChange = true, onChange, onError }: Props): React.JSX.Element { +export default function SourcePermissionControl({ mode, nextSend, disabled, disabledReason, labels, confirmFullAccessOnChange = true, onChange, onError }: Props): React.JSX.Element { const [open, setOpen] = useState(false) const [confirming, setConfirming] = useState(false) const [acknowledged, setAcknowledged] = useState(false) @@ -44,7 +45,8 @@ export default function SourcePermissionControl({ mode, disabled, disabledReason if (disabled) { setOpen(false); setConfirming(false); setAcknowledged(false) } }, [disabled]) const full = mode === 'full-access' - const label = labels?.[mode] ?? t(full ? 'chatInput.permission.fullAccess' : 'chatInput.permission.label') + const modeLabel = labels?.[mode] ?? t(full ? 'chatInput.permission.fullAccess' : 'chatInput.permission.label') + const label = nextSend ? `${modeLabel} · 下次发送生效` : modeLabel useEffect(() => { const observer = new MutationObserver(() => setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) @@ -94,7 +96,7 @@ export default function SourcePermissionControl({ mode, disabled, disabledReason >
-
{full ? t('chatInput.permission.description.fullAccess') +
{nextSend &&

下次发送生效,不影响当前运行、待处理授权或已排队消息。

}{full ? t('chatInput.permission.description.fullAccess') : t('chatInput.permission.description.default').replace('安全沙箱', '默认权限')}
@@ -129,7 +131,7 @@ export default function SourcePermissionControl({ mode, disabled, disabledReason

{t('chatInput.permission.confirm.title')}

-
{t('chatInput.permission.confirm.desc').replace('所有任务', '当前发送的任务')}
+
{t('chatInput.permission.confirm.desc').replace('所有任务', nextSend ? '下次发送的任务(不影响当前运行或授权)' : '之后发送的任务')}
{([ ['folder', 'files'], ['terminal', 'terminal'], ['website', 'internet'] ] as const).map(([icon, key]) =>
diff --git a/src/shared/context-usage.ts b/src/shared/context-usage.ts index 88cb71b..704d706 100644 --- a/src/shared/context-usage.ts +++ b/src/shared/context-usage.ts @@ -62,3 +62,9 @@ export function contextInfoFromMessages(messages: readonly ChatMessage[]): Conte return {sample,breakdown,status:sample||breakdown?(pendingUser?'previous':'latest'):'unavailable', ...(pendingUser?{reason:'新消息尚无对应统计'}:{})} } + +/** Honest provenance for category tooltips; native summary is not our UTF-8 estimator. */ +export function contextBreakdownMethodLabel(method: ContextUsageBreakdown['method']): string { + return { 'native-summary': '内核 summary 本地估计', 'utf8-estimate': 'UTF-8 字节估计', + 'model-tokenizer': '模型 tokenizer 本地计数', 'o200k_base': 'o200k_base 本地计数' }[method] +}