feat: persist context snapshots and defer permission changes to next messages

This commit is contained in:
Codex committed 2026-10-21 17:32:41 +09:00
1 parent 607afd2f39
commit 1a913cdb67
19 files changed
+510 -66

No files matched your search

+11
View File
@@ -695,6 +695,7 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
const result = sendResult('run-order') const result = sendResult('run-order')
const response = deferred<SendResult>() const response = deferred<SendResult>()
let sendCalls = 0 let sendCalls = 0
const enteredSend = deferred<void>()
const events: ChatEvent[] = [ const events: ChatEvent[] = [
{ runId: 'other-run', type: 'text', delta: 'must be ignored' }, { runId: 'other-run', type: 'text', delta: 'must be ignored' },
{ runId: result.runId, type: 'text', delta: 'before' }, { runId: result.runId, type: 'text', delta: 'before' },
@@ -715,6 +716,7 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
installFakeWindow(async () => { installFakeWindow(async () => {
sendCalls++ sendCalls++
enteredSend.resolve()
for (const event of events) useChatStore.getState().applyEvent(event) for (const event of events) useChatStore.getState().applyEvent(event)
return response.promise 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().sending, true, 'sending should cover the pending IPC request')
assert.equal(useChatStore.getState().messages.length, 0, 'early events wait for the send result placeholder') assert.equal(useChatStore.getState().messages.length, 0, 'early events wait for the send result placeholder')
await assert.rejects(useChatStore.getState().send('duplicate'), /提交/) 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') assert.equal(sendCalls, 1, 'a second send must be ignored while the first send is pending')
response.resolve(result) response.resolve(result)
await firstSend await firstSend
assert.equal(sendCalls, 1, 'duplicate must never reach IPC after the first submission completes')
const message = assistantMessage() const message = assistantMessage()
assert.equal(message.turnStartedAt, undefined, 'terminal events clear the live timer timestamp') assert.equal(message.turnStartedAt, undefined, 'terminal events clear the live timer timestamp')
+42 -3
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict' 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' import type { ChatMessage, ContextUsageBreakdown } from '../src/shared/types'
const sample = { const sample = {
@@ -35,12 +36,16 @@ const breakdown: ContextUsageBreakdown = {
} }
function service(overrides: Partial<ContextUsageDependencies> = {}) { function service(overrides: Partial<ContextUsageDependencies> = {}) {
const messages = [assistant()]
const dependencies: ContextUsageDependencies = { const dependencies: ContextUsageDependencies = {
messages: async () => [assistant()], messages: async () => messages,
modelId: async () => 'model-a', modelId: async () => 'model-a',
running: () => false, running: () => false,
summary: async () => undefined, 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 ...overrides
} }
return createContextUsageService(dependencies) 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') 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') 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') 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') const file = join(root, 'permission.json')
let blocked = true let blocked = true
let deliveredPermission: SendPayload['permissionMode'] let deliveredPermission: SendPayload['permissionMode']
assert.deepEqual(resolveQueuedRequestSettings({ assert.deepEqual(resolveQueuedRequestSettings({
queued: false, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox' queued: false, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox'
}), { permissionMode: 'full-access', persistSessionPermission: true }) }), { permissionMode: 'full-access', persistSessionPermission: false })
assert.deepEqual(resolveQueuedRequestSettings({ assert.deepEqual(resolveQueuedRequestSettings({
queued: true, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox' 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, { const queue = createPromptQueue(options(file, {
blocked: () => blocked, blocked: () => blocked,
resolveDispatch: value => ({ resolveDispatch: value => ({
@@ -400,7 +402,7 @@ try {
blocked = false blocked = false
queue.notify('permission') queue.notify('permission')
await waitFor(() => deliveredPermission !== undefined, 'permission delivery') 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') await waitFor(async () => (await queue.get('permission')).items.length === 0, 'permission delivery cleanup')
}) })
} finally { } finally {
+82 -10
View File
@@ -341,7 +341,7 @@ async function testLateResultAndEventStayInOriginalSession(): Promise<void> {
} }
async function testDraftContextWinsWhileFirstSessionIsCreated(): 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 backend = installFakeBackend([], {})
const createGate = deferred<SessionMeta>() const createGate = deferred<SessionMeta>()
const updateGate = deferred<SessionMeta>() const updateGate = deferred<SessionMeta>()
@@ -357,26 +357,28 @@ async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise<void> {
updateCapture = { id: sessionId, patch: { ...patch } } updateCapture = { id: sessionId, patch: { ...patch } }
return updateGate.promise 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++ sendCalls++
return makeSendResult('run-first', 'draft-b', 'Draft B') return makeSendResult('run-first', 'draft-b', 'Draft B')
} }
const sending = useChatStore.getState().send('first message') const sending = useChatStore.getState().send('first message')
await waitFor('first session creation', () => createContext !== undefined) 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().selectModel('draft-b')
await useChatStore.getState().selectThinkingLevel('high') 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) backend.replaceSession(created)
createGate.resolve(created) createGate.resolve(created)
await waitFor('draft context patch after session creation', () => updateCapture !== undefined) await waitFor('draft context patch after session creation', () => updateCapture !== undefined)
assert.deepEqual(updateCapture, { assert.deepEqual(updateCapture, {
id: 'created-session', 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') 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.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')?.modelId, 'draft-b')
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.thinkingLevel, 'high') 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) 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 { function testNextRequestSettingsWhitelist(): void {
assert.deepEqual(nextRequestSettings({ modelId: 'model-next', thinkingLevel: 'high' }), { assert.deepEqual(nextRequestSettings({ modelId: 'model-next', thinkingLevel: 'high' }), {
modelId: 'model-next', modelId: 'model-next',
thinkingLevel: 'high' thinkingLevel: 'high'
}) })
assert.deepEqual(nextRequestSettings({}), {}) assert.deepEqual(nextRequestSettings({}), {})
assert.deepEqual(nextRequestSettings({ permissionMode: 'full-access' }), { permissionMode: 'full-access' })
assert.throws(() => nextRequestSettings({ permissionMode: 'invalid' as never }), /权限模式无效/)
const forbidden: SessionContext[] = [ const forbidden: SessionContext[] = [
{ permissionMode: 'full-access' },
{ cwd: 'C:\\workspace' }, { cwd: 'C:\\workspace' },
{ agentMode: 'plan' }, { agentMode: 'plan' },
{ projectId: 'project-1' }, { projectId: 'project-1' },
{ workspaceId: 'workspace-1' }, { workspaceId: 'workspace-1' },
{ expertId: 'expert-1' }, { expertId: 'expert-1' },
{ modelId: 'model-next', permissionMode: 'default-sandbox' }
] ]
for (const patch of forbidden) { for (const patch of forbidden) {
assert.throws(() => nextRequestSettings(patch), /运行期间只能修改下次发送的模型和思考强度/) assert.throws(() => nextRequestSettings(patch), /运行期间只能修改下次发送的模型、思考强度和权限/)
} }
assert.throws(() => nextRequestSettings({ modelId: ' ' }), /模型标识无效/) assert.throws(() => nextRequestSettings({ modelId: ' ' }), /模型标识无效/)
assert.throws(() => nextRequestSettings({ thinkingLevel: 'invalid' as never }), /无效的思考强度/) assert.throws(() => nextRequestSettings({ thinkingLevel: 'invalid' as never }), /无效的思考强度/)
@@ -431,6 +499,10 @@ try {
completed.push('late result/event session isolation') completed.push('late result/event session isolation')
await testDraftContextWinsWhileFirstSessionIsCreated() await testDraftContextWinsWhileFirstSessionIsCreated()
completed.push('draft settings during first session creation') 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() testNextRequestSettingsWhitelist()
completed.push('nextRequestSettings whitelist') completed.push('nextRequestSettings whitelist')
console.log(JSON.stringify({ result: 'PASS', tests: completed }, null, 2)) console.log(JSON.stringify({ result: 'PASS', tests: completed }, null, 2))
+53 -1
View File
@@ -11,7 +11,7 @@ interface SessionStoreApi {
listSessions(): Promise<SessionMeta[]> listSessions(): Promise<SessionMeta[]>
createSession(title?: string): Promise<SessionMeta> createSession(title?: string): Promise<SessionMeta>
appendMessage(sessionId: string, message: ChatMessage): Promise<void> 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 { interface IpcModule {
@@ -22,6 +22,7 @@ interface IpcModule {
interface RegressionState { interface RegressionState {
userData: string userData: string
handlers: Map<string, (...args: unknown[]) => unknown> handlers: Map<string, (...args: unknown[]) => unknown>
kernel?: {getContextSnapshotIdentity():unknown;getContextBreakdown():Promise<unknown>}
} }
const STATE_KEY = '__YCODE_SESSION_STORE_REGRESSION__' const STATE_KEY = '__YCODE_SESSION_STORE_REGRESSION__'
@@ -100,6 +101,20 @@ async function main(): Promise<void> {
logLevel: 'silent', logLevel: 'silent',
plugins: [ plugins: [
testTerminalHostPlugin, 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', name: 'session-store-electron-mock',
setup(pluginBuild) { 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 inMemory = (await invoke('session:messages', targetSession.id)) as ChatMessage[]
const diskSessions = await readStoredSessions(sessionsFile) const diskSessions = await readStoredSessions(sessionsFile)
const diskMessages = diskSessions.find((item) => item.id === targetSession.id)?.messages 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 coldMessages = (await invoke('session:messages', targetSession.id)) as ChatMessage[]
const coldSessions = (await invoke('session:list')) as SessionMeta[] const coldSessions = (await invoke('session:list')) as SessionMeta[]
assert.deepEqual(coldMessages, inMemory, 'a cold module import should recover saved messages from disk') 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') assert.equal(coldSessions.length, 12, 'a cold module import should recover all concurrent sessions')
await rm(sessionsFile) await rm(sessionsFile)
await mkdir(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'), await assert.rejects(coldModule.sessions.createSession('transient write failure'),
'a failed atomic rename must reject its caller') 'a failed atomic rename must reject its caller')
const leftovers = (await readdir(state.userData)).filter((name) => /^\.sessions-.*\.tmp$/.test(name)) 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 })
}
+9
View File
@@ -1137,6 +1137,12 @@ export class ClaudeKernel {
} finally { if (timer) clearTimeout(timer) } } 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<ContextUsageBreakdown | undefined> { async getContextBreakdown(): Promise<ContextUsageBreakdown | undefined> {
const requestId = this.lastRequestId const requestId = this.lastRequestId
const groupKey = this.lastGroupKey const groupKey = this.lastGroupKey
@@ -1146,13 +1152,16 @@ export class ClaudeKernel {
const result = await this.getContextSummary() const result = await this.getContextSummary()
if (!result || !requestId || requestId !== this.lastRequestId || groupKey !== this.lastGroupKey || instance !== this.instance) return undefined 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 } 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) { for (const category of result.categories) {
if (category.kind !== 'used' || !Number.isFinite(category.tokens) || category.tokens < 0) continue if (category.kind !== 'used' || !Number.isFinite(category.tokens) || category.tokens < 0) continue
observedCategories++
const id = category.id const id = category.id
const key = id === 'system_prompt' || id === 'memory_files' ? 'system' : id === 'system_tools' ? 'tools' : 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' id === 'messages' ? 'conversation' : id === 'mcp_tools' || id === 'mcp_instructions' ? 'mcp' : id === 'skills' ? 'skills' : 'other'
counts[key] += category.tokens counts[key] += category.tokens
} }
if (!observedCategories) return undefined
const contextWindow = this.options.model.profile.contextWindowTokens ?? this.options.model.profile.maxInputTokens const contextWindow = this.options.model.profile.contextWindowTokens ?? this.options.model.profile.maxInputTokens
return { requestId, sequence: sampledAt, sampledAt, modelId: this.options.model.profile.id, return { requestId, sequence: sampledAt, sampledAt, modelId: this.options.model.profile.id,
contextWindow, method: 'native-summary', scope: 'prepared-request', counts, contextWindow, method: 'native-summary', scope: 'prepared-request', counts,
+3 -5
View File
@@ -11,14 +11,12 @@ export interface QueuedRequestSettings {
persistSessionPermission: boolean 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 { export function resolveQueuedRequestSettings(input: QueuedRequestSettingsInput): QueuedRequestSettings {
const permissionMode = input.queued const permissionMode = input.payloadPermissionMode ?? (input.queued ? 'default-sandbox' : input.sessionPermissionMode) ?? 'default-sandbox'
? input.sessionPermissionMode ?? 'default-sandbox'
: input.payloadPermissionMode ?? input.sessionPermissionMode ?? 'default-sandbox'
if (permissionMode !== 'default-sandbox' && permissionMode !== 'full-access') throw new Error('权限模式无效') if (permissionMode !== 'default-sandbox' && permissionMode !== 'full-access') throw new Error('权限模式无效')
return { return {
permissionMode, permissionMode,
persistSessionPermission: !input.queued && input.sessionPermissionMode !== permissionMode persistSessionPermission: !input.queued && input.sessionPermissionMode === undefined
} }
} }
+6 -2
View File
@@ -4,8 +4,8 @@ import { thinkingLevel } from './mode-policy'
/** During a run only future-request settings may change, never active policy. */ /** During a run only future-request settings may change, never active policy. */
export function nextRequestSettings(patch: SessionContext): SessionContext { export function nextRequestSettings(patch: SessionContext): SessionContext {
if (!patch || typeof patch !== 'object' || Array.isArray(patch) || if (!patch || typeof patch !== 'object' || Array.isArray(patch) ||
Object.keys(patch).some(key => key !== 'modelId' && key !== 'thinkingLevel')) { Object.keys(patch).some(key => key !== 'modelId' && key !== 'thinkingLevel' && key !== 'permissionMode')) {
throw new Error('运行期间只能修改下次发送的模型和思考强度') throw new Error('运行期间只能修改下次发送的模型、思考强度和权限')
} }
const result: SessionContext = {} const result: SessionContext = {}
if (patch.modelId !== undefined) { if (patch.modelId !== undefined) {
@@ -13,5 +13,9 @@ export function nextRequestSettings(patch: SessionContext): SessionContext {
result.modelId = patch.modelId result.modelId = patch.modelId
} }
if (patch.thinkingLevel !== undefined) result.thinkingLevel = thinkingLevel(patch.thinkingLevel) 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 return result
} }
+4 -2
View File
@@ -239,8 +239,8 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
content: message.content, tools: message.tools, parts: message.parts, interactions: message.interactions, content: message.content, tools: message.tools, parts: message.parts, interactions: message.interactions,
status: message.status, turnStartedAt: message.turnStartedAt, turnDurationMs: message.turnDurationMs, turnFinishedAt: message.turnFinishedAt, status: message.status, turnStartedAt: message.turnStartedAt, turnDurationMs: message.turnDurationMs, turnFinishedAt: message.turnFinishedAt,
contextUsage: message.contextUsage, contextBreakdown: message.contextBreakdown, error: message.error, 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) (error) => console.error('会话增量保存失败,将在回合结束时重试', error)
) )
let checkpoint = createCheckpoint() 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('当前运行不能接收引导消息') 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)) const pendingInteraction = () => assistant.interactions?.some(item => ['pending', 'responding', 'error'].includes(item.status))
if (pendingInteraction()) notSubmitted('请先处理当前问答或授权') 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('追加消息已提交,不能重复执行') 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 files = await Promise.all((input.attachments ?? []).map(item => getAttachment(item.id)))
const attachment = await attachmentPrompt(files) const attachment = await attachmentPrompt(files)
+36 -15
View File
@@ -1,33 +1,54 @@
import type { ChatMessage, ContextUsageBreakdown, ContextUsageInfo, ContextUsageSnapshot } from '../shared/types' import type { ChatMessage, ContextUsageBreakdown, ContextUsageInfo } from '../shared/types'
import { contextInfoFromMessages } from '../shared/context-usage' import { contextInfoFromMessages, normalizeContextUsageBreakdown } from '../shared/context-usage'
export interface ContextSnapshotGuard {
branch: string
assistantId: string
requestId: string
modelId: string
hostIdentity?: unknown
}
export interface ContextUsageDependencies { export interface ContextUsageDependencies {
messages(id:string):Promise<ChatMessage[]> messages(id:string):Promise<ChatMessage[]>
modelId(id:string):Promise<string|undefined> modelId(id:string):Promise<string|undefined>
running(id:string):boolean running(id:string):boolean
summary?(id:string):Promise<ContextUsageBreakdown|undefined> summary?(id:string):Promise<ContextUsageBreakdown|undefined>
/** The host rechecks identity inside its existing session mutation lock. */ captureIdentity?(id:string):Promise<unknown>
persistIfCurrent(id:string,assistantId:string,requestId:string|undefined,sample:ContextUsageSnapshot):Promise<void> /** Recheck the branch, request, model and host identity inside the session mutation lock. */
persistIfCurrent(id:string,guard:ContextSnapshotGuard,breakdown:ContextUsageBreakdown):Promise<void>
} }
const latestAssistant=(messages:ChatMessage[])=>messages.findLast(message=>message.role==='assistant') 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<ContextUsageInfo>} { export function createContextUsageService(deps:ContextUsageDependencies):{get(id:string):Promise<ContextUsageInfo>} {
return {async get(id) { return {async get(id) {
let messages=await deps.messages(id) let messages=await deps.messages(id)
let summary:ContextUsageBreakdown|undefined const original=latestAssistant(messages)
try { summary=await deps.summary?.(id) } catch { /* Retain real stream samples on query failure. */ } // Copy scalar identity before any await: the store's array and messages are mutable.
if(summary)messages=await deps.messages(id) 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) const assistant=latestAssistant(messages)
let info=contextInfoFromMessages(messages) const 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 selectedModelId=await deps.modelId(id) const selectedModelId=await deps.modelId(id)
const running=deps.running(id) const running=deps.running(id)
const decorate=(value:ContextUsageInfo):ContextUsageInfo=>{ const model=info.sample?.modelId??info.breakdown?.modelId??assistant?.modelId
const model=value.sample?.modelId??value.breakdown?.modelId??assistant?.modelId
return model&&selectedModelId&&model!==selectedModelId&&!running&&assistant?.status!=='streaming'
?{...value,status:'previous',reason:'来自切换模型前的最近请求'}:value
}
if(!assistant)return {status:'unavailable',reason:'发送消息后可查看用量'} if(!assistant)return {status:'unavailable',reason:'发送消息后可查看用量'}
return decorate(info) return model&&selectedModelId&&model!==selectedModelId&&!running&&assistant.status!=='streaming'
?{...info,status:'previous',reason:'来自切换模型前的最近请求'}:info
}} }}
} }
+38 -8
View File
@@ -48,7 +48,7 @@ import { FileArtifactIndexRepository } from './library/artifact-repository'
import { ArtifactIndexService } from './library/artifact-service' import { ArtifactIndexService } from './library/artifact-service'
import { LibraryService } from './library/service' import { LibraryService } from './library/service'
import { JsonLibraryUserStateRepository, libraryUserStatePath } from './library/user-state' 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 { nextRequestSettings } from './agent/request-settings'
import { createPromptQueue } from './prompt-queue' import { createPromptQueue } from './prompt-queue'
import * as skills from './capabilities/skills' import * as skills from './capabilities/skills'
@@ -57,7 +57,6 @@ import { getRecommendedSkills, installRecommendedSkill, cancelRecommendedSkillIn
initializeRecommendedSkillInstalls, stopRecommendedSkillInstalls } from './capabilities/recommended-skills' initializeRecommendedSkillInstalls, stopRecommendedSkillInstalls } from './capabilities/recommended-skills'
import type { RecommendedSkillInstallOptions } from '@shared/capabilities' import type { RecommendedSkillInstallOptions } from '@shared/capabilities'
import * as mcp from './capabilities/mcp' import * as mcp from './capabilities/mcp'
import { contextInfoFromMessages } from '@shared/context-usage'
import { assertRuntimeAvailable, withRuntimeStart } from './update-maintenance' import { assertRuntimeAvailable, withRuntimeStart } from './update-maintenance'
let automationHost: ReturnType<typeof createAutomationHost> | undefined let automationHost: ReturnType<typeof createAutomationHost> | undefined
@@ -106,6 +105,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
file: join(app.getPath('userData'), 'prompt-queues.json'), file: join(app.getPath('userData'), 'prompt-queues.json'),
validate: async payload => { validate: async payload => {
await assertQueueSession(payload.sessionId) 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 (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 (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('请先处理当前问答或授权') 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) 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: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) => { ipcMain.handle('chat:queue:restore-edit', async (_event, sessionId: string, id: string) => {
await assertQueueSession(sessionId) await assertQueueSession(sessionId)
if (typeof id !== 'string') throw new Error('编辑取回标识无效') 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 () => { ipcMain.handle('session:update-context', (_event, id: string, patch: SessionContext) => withChatSessionLock(id, async () => {
if ([...runTasks.values()].some((run) => run.sessionId === id)) { if ([...runTasks.values()].some((run) => run.sessionId === id)) {
const next = nextRequestSettings(patch) 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('模型不存在') if (next.modelId && !(await models.resolveModel(next.modelId))) throw new Error('模型不存在')
return sessions.updateSessionContext(id, next) 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.modelId !== undefined) context.modelId = patch.modelId
if (patch.thinkingLevel !== undefined) context.thinkingLevel = thinkingLevel(patch.thinkingLevel) if (patch.thinkingLevel !== undefined) context.thinkingLevel = thinkingLevel(patch.thinkingLevel)
if (patch.permissionMode !== undefined) { if (patch.permissionMode !== undefined) {
if (agentMode(context.agentMode) !== 'craft') throw new Error('计划和仅问答模式下不能修改权限')
if (patch.permissionMode !== 'default-sandbox' && patch.permissionMode !== 'full-access') throw new Error('权限模式无效') if (patch.permissionMode !== 'default-sandbox' && patch.permissionMode !== 'full-access') throw new Error('权限模式无效')
context.permissionMode = patch.permissionMode context.permissionMode = patch.permissionMode
} }
@@ -562,15 +572,35 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
return sessions.updateSessionContext(id, context) return sessions.updateSessionContext(id, context)
})) }))
ipcMain.handle('session:messages', (_event, sessionId: string) => sessions.getMessages(sessionId)) 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, const contextUsageService=createContextUsageService({messages:sessions.getMessages,
summary: async id => cachedClaudeKernel(id)?.getContextBreakdown(), summary: async id => cachedClaudeKernel(id)?.getContextBreakdown(),
captureIdentity: id => withChatSessionLock(id, () => captureContextIdentity(id)),
modelId:async id=>(await sessions.getSessionContext(id)).modelId, modelId:async id=>(await sessions.getSessionContext(id)).modelId,
running:id=>[...runTasks.values()].some(run=>run.sessionId===id), running:id=>[...runTasks.values()].some(run=>run.sessionId===id),
persistIfCurrent:(id,assistantId,requestId,sample)=>withChatSessionLock(id,async()=>{ persistIfCurrent:(id,guard,breakdown)=>withChatSessionLock(id,async()=>{
if([...runTasks.values()].some(run=>run.sessionId===id))return const before=guard.hostIdentity as Awaited<ReturnType<typeof captureContextIdentity>> | undefined
const latest=(await sessions.getMessages(id)).findLast(message=>message.role==='assistant') if(!before)return
if(latest?.id===assistantId&&latest.requestId===requestId&&!contextInfoFromMessages([latest]).sample) const now=await captureContextIdentity(id)
await sessions.patchMessage(id,assistantId,{contextUsage:sample},{touchSession:false}) 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:context-usage',(_event,id:string)=>contextUsageService.get(id))
ipcMain.handle('session:rename', (_event, sessionId: string, title: string) => ipcMain.handle('session:rename', (_event, sessionId: string, title: string) =>
+11 -1
View File
@@ -203,13 +203,23 @@ export async function patchMessage(
sessionId: string, sessionId: string,
messageId: string, messageId: string,
patch: Partial<ChatMessage>, patch: Partial<ChatMessage>,
options: { touchSession?: boolean } = {} options: { touchSession?: boolean; preserveContextBreakdown?: boolean } = {}
): Promise<void> { ): Promise<void> {
const store = await load() const store = await load()
const session = store.sessions.find((s) => s.id === sessionId) const session = store.sessions.find((s) => s.id === sessionId)
if (!session) return if (!session) return
const message = session.messages.find((m) => m.id === messageId) const message = session.messages.find((m) => m.id === messageId)
if (!message) return 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) Object.assign(message, patch)
if(options.touchSession!==false)session.updatedAt = Date.now() if(options.touchSession!==false)session.updatedAt = Date.now()
await persist() await persist()
+5 -2
View File
@@ -723,13 +723,16 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
speech={{ transcribe: ({ wavBase64, mimeType }) => window.ycode.speech.transcribe({ audioBase64: wavBase64, mimeType }) }} speech={{ transcribe: ({ wavBase64, mimeType }) => window.ycode.speech.transcribe({ audioBase64: wavBase64, mimeType }) }}
onTranscript={(text) => setDraft((current) => `${current}${current ? ' ' : ''}${text}`)} onTranscript={(text) => setDraft((current) => `${current}${current ? ' ' : ''}${text}`)}
permissionPlacement="toolbar" permissionPlacement="toolbar"
permissionSlot={<SourcePermissionControl mode={permissionMode} disabled={sending || loading || !!loadError || !!running || effectiveAgentMode !== 'craft' || composition.input.disabled} permissionSlot={<SourcePermissionControl key={draftKey} mode={permissionMode} nextSend={sending || !!running} disabled={loading || !!loadError || effectiveAgentMode !== 'craft'}
disabledReason={effectiveAgentMode !== 'craft' ? '计划和仅问答模式下不能修改权限' : undefined} disabledReason={effectiveAgentMode !== 'craft' ? '计划和仅问答模式下不能修改权限' : undefined}
onChange={async (mode) => { onChange={async (mode) => {
await useChatStore.getState().selectPermissionMode(mode) await useChatStore.getState().selectPermissionMode(mode)
try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ } 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 ? () => { onSend={submit} sending={sending} onStop={running ? () => {
const state = useChatStore.getState() const state = useChatStore.getState()
if (state.currentSessionId !== currentId || state.running?.runId !== running.runId || hidden) return Promise.reject(new Error('运行所属会话已切换')) if (state.currentSessionId !== currentId || state.running?.runId !== running.runId || hidden) return Promise.reject(new Error('运行所属会话已切换'))
+20 -6
View File
@@ -128,6 +128,7 @@ let expertSelectionVersion = 0
let expertWrites: Promise<unknown> = Promise.resolve() let expertWrites: Promise<unknown> = Promise.resolve()
let requestSettingsWrites: Promise<unknown> = Promise.resolve() let requestSettingsWrites: Promise<unknown> = Promise.resolve()
// Serialization is global, but a failed/pending setting only gates its own owner. // Serialization is global, but a failed/pending setting only gates its own owner.
const permissionSelections = new Map<string, Promise<PermissionMode>>()
const requestSettingsByOwner = new Map<string, Promise<unknown>>() const requestSettingsByOwner = new Map<string, Promise<unknown>>()
const requestSettingErrors = new Map<string, Map<keyof SessionContext, unknown>>() const requestSettingErrors = new Map<string, Map<keyof SessionContext, unknown>>()
function trackRequestSettings(owner: string, write: Promise<unknown>, keys: readonly (keyof SessionContext)[]): void { function trackRequestSettings(owner: string, write: Promise<unknown>, keys: readonly (keyof SessionContext)[]): void {
@@ -355,10 +356,14 @@ export const useChatStore = create<ChatState>((set, get) => {
await saveRequestSettings({ modelId: id }) await saveRequestSettings({ modelId: id })
}, },
async selectPermissionMode(permissionMode) { 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 const mode = (get().currentSessionId ? get().sessions.find(item => item.id === get().currentSessionId) : get().draftContext)?.agentMode
if (mode && mode !== 'craft') throw new Error('计划和仅问答模式下不能修改权限') 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) { async selectExpert(id) {
await get().summonExpert(id) await get().summonExpert(id)
@@ -435,11 +440,16 @@ export const useChatStore = create<ChatState>((set, get) => {
const patch: SessionContext = {} const patch: SessionContext = {}
if (latest.modelId !== context?.modelId && latest.modelId !== undefined) patch.modelId = latest.modelId 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.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) { if (Object.keys(patch).length) {
const id = created.id const id = created.id
const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(id, patch)) const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(id, patch))
trackRequestSettings(id, write, Object.keys(patch) as (keyof SessionContext)[]) 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) void window.ycode.chat.setSessionVisible(created.id)
@@ -521,6 +531,10 @@ export const useChatStore = create<ChatState>((set, get) => {
void modelWrite?.catch(() => {}) void modelWrite?.catch(() => {})
// Read the saved choice at submission time; a React effect is not authoritative. // Read the saved choice at submission time; a React effect is not authoritative.
const savedPermission = context?.permissionMode ?? options.permissionMode ?? 'default-sandbox' 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 } options = { ...options, permissionMode: savedPermission }
if (options.userContent) options = { ...options, userContent: trimUserContent(options.userContent) } 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 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<ChatState>((set, get) => {
if (options.action || options.command) throw new Error('运行期间不能重新发送或压缩上下文') if (options.action || options.command) throw new Error('运行期间不能重新发送或压缩上下文')
if (modelWrite) await modelWrite if (modelWrite) await modelWrite
await settleRequestModel(sessionId) 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 return
} }
if (!sessionId || get().sendingSessions.includes(sessionId)) throw new Error('消息正在提交,请稍后重试') if (!sessionId || get().sendingSessions.includes(sessionId)) throw new Error('消息正在提交,请稍后重试')
@@ -559,7 +573,7 @@ export const useChatStore = create<ChatState>((set, get) => {
if (options.action || options.command) throw new Error('请先处理待发送队列') if (options.action || options.command) throw new Error('请先处理待发送队列')
if (modelWrite) await modelWrite if (modelWrite) await modelWrite
await settleRequestModel(sessionId) 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 return
} }
const owner = sessionId const owner = sessionId
@@ -569,7 +583,7 @@ export const useChatStore = create<ChatState>((set, get) => {
try { try {
if (modelWrite) await modelWrite if (modelWrite) await modelWrite
await settleRequestModel(owner) 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', const placeholder: ChatMessage = { id: result.assistantMessageId, requestId: result.runId, role: 'assistant',
content: '', tools: [], parts: [], modelId: result.modelId, modelName: result.modelName, content: '', tools: [], parts: [], modelId: result.modelId, modelName: result.modelName,
assistantDisplay: result.assistantDisplay, createdAt: Date.now(), assistantDisplay: result.assistantDisplay, createdAt: Date.now(),
@@ -17,7 +17,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { GitBranchInfo, ContextUsageInfo, ContextUsageCategory } from '@shared/types' 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 { IconClose } from '../../../components/icons'
import { useChatStore } from '../../../stores/chat' import { useChatStore } from '../../../stores/chat'
import { BranchCopyIcon, BranchGlyphIcon } from './icons' import { BranchCopyIcon, BranchGlyphIcon } from './icons'
@@ -238,7 +238,7 @@ export function ContextUsageRing({ sessionId, hasMessages, running }: ContextUsa
<div className="cr-context-usage-popover__row" key={category}> <div className="cr-context-usage-popover__row" key={category}>
<span className={`cr-context-usage-popover__swatch cr-context-usage-popover__swatch--${style}`} /> <span className={`cr-context-usage-popover__swatch cr-context-usage-popover__swatch--${style}`} />
<span className="cr-context-usage-popover__row-name">{label}</span> <span className="cr-context-usage-popover__row-name">{label}</span>
<span className="cr-context-usage-popover__row-value-tooltip" title={breakdown?`${breakdown.counts[category]} tokens,UTF-8 字节估计;${new Date(breakdown.sampledAt).toLocaleString()}`:'此请求未保留分类统计'}><span className="cr-context-usage-popover__row-value">{breakdown?`≈${formatContextTokenCount(breakdown.counts[category])}`:'—'}</span></span> <span className="cr-context-usage-popover__row-value-tooltip" title={breakdown?`${breakdown.counts[category]} tokens,${contextBreakdownMethodLabel(breakdown.method)};${new Date(breakdown.sampledAt).toLocaleString()}`:'此请求未保留分类统计'}><span className="cr-context-usage-popover__row-value">{breakdown?`≈${formatContextTokenCount(breakdown.counts[category])}`:'—'}</span></span>
</div> </div>
))} ))}
</div> </div>
@@ -246,7 +246,7 @@ export function ContextUsageRing({ sessionId, hasMessages, running }: ContextUsa
<span>{sourceLabel}{sampledAt?` · ${new Date(sampledAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`:''}</span> <span>{sourceLabel}{sampledAt?` · ${new Date(sampledAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`:''}</span>
<button type="button" className="yc-context-refresh" aria-label="重新获取用量" disabled={loading} onClick={()=>setRefresh(value=>value+1)}>刷新</button> <button type="button" className="yc-context-refresh" aria-label="重新获取用量" disabled={loading} onClick={()=>setRefresh(value=>value+1)}>刷新</button>
</div> </div>
{breakdown?<div className="yc-context-note">分类为本地估计{breakdown.partial?',未包含部分输入':''},与上游总量可能不同</div>:<div className="yc-context-note">此历史请求未保存分类明细</div>} {breakdown?<div className="yc-context-note" title={new Date(breakdown.sampledAt).toLocaleString()}>分类快照 · {new Date(breakdown.sampledAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})} · {contextBreakdownMethodLabel(breakdown.method)}{breakdown.partial?',未包含部分输入':''},与上游总量可能不同</div>:<div className="yc-context-note">此历史请求未保存分类明细</div>}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -18,6 +18,7 @@ const CheckGlyph = SelectCheckGlyph as ComponentType<SVGProps<SVGSVGElement>>
interface Props { interface Props {
mode: PermissionMode mode: PermissionMode
nextSend?: boolean
disabled?: boolean disabled?: boolean
disabledReason?: string disabledReason?: string
labels?: Partial<Record<PermissionMode, string>> labels?: Partial<Record<PermissionMode, string>>
@@ -30,7 +31,7 @@ interface Props {
* The source panel has one full-access switch, not a two-option permission list. * 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. * 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 [open, setOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [acknowledged, setAcknowledged] = 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) } if (disabled) { setOpen(false); setConfirming(false); setAcknowledged(false) }
}, [disabled]) }, [disabled])
const full = mode === 'full-access' 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(() => { useEffect(() => {
const observer = new MutationObserver(() => setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')) const observer = new MutationObserver(() => setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'))
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
@@ -94,7 +96,7 @@ export default function SourcePermissionControl({ mode, disabled, disabledReason
> >
<div className="yc-source-model-selector yc-source-permission cr-theme" data-cr-theme={theme} data-theme={theme}> <div className="yc-source-model-selector yc-source-permission cr-theme" data-cr-theme={theme} data-theme={theme}>
<div className="cr-permission-panel wb-composer__permission-menu" role="menu" aria-label="权限设置"> <div className="cr-permission-panel wb-composer__permission-menu" role="menu" aria-label="权限设置">
<div className="cr-permission-panel__desc">{full ? t('chatInput.permission.description.fullAccess') <div className="cr-permission-panel__desc">{nextSend && <p>下次发送生效,不影响当前运行、待处理授权或已排队消息。</p>}{full ? t('chatInput.permission.description.fullAccess')
: t('chatInput.permission.description.default').replace('安全沙箱', '默认权限')}</div> : t('chatInput.permission.description.default').replace('安全沙箱', '默认权限')}</div>
<div className="cr-permission-panel__divider" role="separator" /> <div className="cr-permission-panel__divider" role="separator" />
<div className="cr-permission-panel__group"> <div className="cr-permission-panel__group">
@@ -129,7 +131,7 @@ export default function SourcePermissionControl({ mode, disabled, disabledReason
<span className="permission-confirm-icon"><ShieldErrorIcon /></span><h2 className="wb-modal__title" id={`${id}-title`}>{t('chatInput.permission.confirm.title')}</h2> <span className="permission-confirm-icon"><ShieldErrorIcon /></span><h2 className="wb-modal__title" id={`${id}-title`}>{t('chatInput.permission.confirm.title')}</h2>
</div></div></div> </div></div></div>
<div className="wb-modal__body cr-permission-confirm__modal-body"> <div className="wb-modal__body cr-permission-confirm__modal-body">
<div className="cr-permission-confirm__desc" id={`${id}-description`}>{t('chatInput.permission.confirm.desc').replace('所有任务', '当前发送的任务')}</div> <div className="cr-permission-confirm__desc" id={`${id}-description`}>{t('chatInput.permission.confirm.desc').replace('所有任务', nextSend ? '下次发送的任务(不影响当前运行或授权)' : '之后发送的任务')}</div>
<div className="cr-permission-confirm__capabilities">{([ <div className="cr-permission-confirm__capabilities">{([
['folder', 'files'], ['terminal', 'terminal'], ['website', 'internet'] ['folder', 'files'], ['terminal', 'terminal'], ['website', 'internet']
] as const).map(([icon, key]) => <div key={key} className="cr-permission-confirm__capability-item"> ] as const).map(([icon, key]) => <div key={key} className="cr-permission-confirm__capability-item">
+6
View File
@@ -62,3 +62,9 @@ export function contextInfoFromMessages(messages: readonly ChatMessage[]): Conte
return {sample,breakdown,status:sample||breakdown?(pendingUser?'previous':'latest'):'unavailable', return {sample,breakdown,status:sample||breakdown?(pendingUser?'previous':'latest'):'unavailable',
...(pendingUser?{reason:'新消息尚无对应统计'}:{})} ...(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]
}