feat: persist context snapshots and defer permission changes to next messages
This commit is contained in:
1 parent
607afd2f39
commit
1a913cdb67
19 files changed
+510
-66
No files matched your search
@@ -695,6 +695,7 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
|
|||||||
const result = sendResult('run-order')
|
const 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')
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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('运行所属会话已切换'))
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user