feat: align conversation state, composer and scoped approvals

This commit is contained in:
dot committed 2026-10-21 03:46:53 +09:00
1 parent 771ca540f7
commit 308b98df23
61 files changed
+1701 -292

No files matched your search

@@ -0,0 +1,206 @@
import assert from 'node:assert/strict'
import { performance } from 'node:perf_hooks'
import { renderToStaticMarkup } from 'react-dom/server'
import { useChatStore } from '../src/renderer/src/stores/chat'
import { useSettingsStore } from '../src/renderer/src/stores/settings'
import { ModeChip } from '../src/renderer/src/workbuddy/composer/ModeChip'
import { SessionLoading } from '../src/renderer/src/components/SessionLoading'
import type { ChatMessage, SessionMeta } from '../src/shared/types'
import type { SendPayload } from '../src/shared/ipc'
const model = { id: 'valid', name: 'Configured', model: 'configured', providerId: 'fixture', baseUrl: 'http://localhost', hasApiKey: false, keyStorage: 'none' as const }
const session = (id: string): SessionMeta => ({ id, title: id, modelId: model.id, createdAt: 1, updatedAt: 1 })
const message = (id: string): ChatMessage => ({ id, role: 'user', content: id, createdAt: 1 })
const deferred = <T,>() => { let resolve!: (value: T) => void; let reject!: (reason: Error) => void; const promise = new Promise<T>((a,b) => { resolve=a; reject=b }); return { promise, resolve, reject } }
let sent: SendPayload | undefined
function setup() {
useSettingsStore.setState({ models: [model], selectedModelId: model.id })
useChatStore.setState({ sessions: ['a','b','c'].map(session), currentSessionId: null, messages: [], sessionMessages: {}, runs: {}, runSequences: {}, running: null, sending: false, sendingSessions: [], loading: false, loadError: null, error: null, draftContext: {}, draftId: 0 })
sent = undefined
Object.defineProperty(globalThis, 'window', { configurable: true, value: { ycode: {
session: {
list: async () => useChatStore.getState().sessions,
messages: async (id: string) => [message(id)],
updateContext: async (id: string, patch: object) => ({ ...session(id), ...patch }),
create: async (_title: unknown, context: object) => ({ ...session('new'), ...context })
}, chat: {
activeRun: async () => null,
setSessionVisible: async () => {},
send: async (payload: SendPayload) => { sent=payload; return { runId: 'run', assistantMessageId: 'answer', userMessage: message('question'), modelId: model.id } }
}
} } })
}
async function main() {
setup()
const loading = deferred<ChatMessage[]>()
window.ycode.session.messages = () => loading.promise
const selection = useChatStore.getState().selectSession('a')
assert.equal(useChatStore.getState().currentSessionId, 'a')
assert.equal(useChatStore.getState().loading, true)
assert.equal(useChatStore.getState().messages.length, 0)
await assert.rejects(useChatStore.getState().send('no'), /加载/)
loading.resolve([]); await selection
assert.equal(useChatStore.getState().loading, false)
assert.equal(useChatStore.getState().currentSessionId, 'a', 'empty existing session stays an existing session')
let reads = 0
window.ycode.session.messages = async () => { reads++; return [] }
await useChatStore.getState().selectSession('a')
assert.equal(reads, 0, 'ready reselect does not reload or flicker')
setup()
const cached = [message('cached')]
useChatStore.setState({ sessionMessages: { b: cached } })
const pending = deferred<ChatMessage[]>()
window.ycode.session.messages = () => pending.promise
const withCache = useChatStore.getState().selectSession('b')
assert.equal(useChatStore.getState().messages, cached)
pending.reject(new Error('load failure')); await withCache
assert.equal(useChatStore.getState().loadError, 'load failure')
assert.equal(useChatStore.getState().loading, false)
await assert.rejects(useChatStore.getState().send('no'), /加载失败/)
window.ycode.session.messages = async () => [message('retried')]
await useChatStore.getState().selectSession('b')
assert.equal(useChatStore.getState().loadError, null)
assert.equal(useChatStore.getState().messages[0].content, 'retried')
setup()
const waits = { a: deferred<ChatMessage[]>(), b: deferred<ChatMessage[]>(), c: deferred<ChatMessage[]>() }
window.ycode.session.messages = id => waits[id as keyof typeof waits].promise
const a=useChatStore.getState().selectSession('a'); await Promise.resolve()
const b=useChatStore.getState().selectSession('b'); await Promise.resolve()
const c=useChatStore.getState().selectSession('c'); await Promise.resolve()
waits.c.resolve([message('c')]); await c
waits.b.resolve([message('b')]); await b
waits.a.resolve([message('a')]); await a
assert.equal(useChatStore.getState().messages[0].content, 'c')
setup()
useSettingsStore.setState({ models: [], selectedModelId: null })
await assert.rejects(useChatStore.getState().send('no model'), /配置自定义模型/)
assert.equal(sent, undefined)
useSettingsStore.setState({ models: [model], selectedModelId: model.id })
useChatStore.setState({ draftContext: { modelId: 'deleted', permissionMode: 'full-access' } })
await useChatStore.getState().send('first full access', { permissionMode: 'default-sandbox' })
assert.equal(sent?.permissionMode, 'full-access', 'saved draft is authoritative over stale UI')
assert.equal(useChatStore.getState().sessions.find(item => item.id === 'new')?.modelId, 'valid')
assert.equal(useChatStore.getState().sessions.find(item => item.id === 'new')?.permissionMode, 'full-access')
for (const mode of ['plan', 'ask', 'craft'] as const) {
setup()
useChatStore.setState({ currentSessionId: 'a', sessions: [{ ...session('a'), agentMode: mode, permissionMode: 'full-access' }] })
await useChatStore.getState().send('mode test', { permissionMode: 'default-sandbox' })
assert.equal(sent?.permissionMode, 'full-access', 'request carries saved preference; backend mode policy enforces plan restrictions')
assert.equal(useChatStore.getState().sessions[0].permissionMode, 'full-access', 'mode does not destroy saved preference')
}
setup()
useChatStore.setState({ currentSessionId: 'a' })
window.ycode.session.updateContext = async () => { throw new Error('save failed') }
await assert.rejects(useChatStore.getState().selectAgentMode('plan'), /save failed/)
assert.equal(useChatStore.getState().sessions[0].agentMode, undefined)
window.ycode.session.updateContext = async (id, patch) => ({ ...session(id), ...patch })
await useChatStore.getState().selectAgentMode('craft')
setup()
useChatStore.setState({ currentSessionId: 'a' })
const permissionSave = deferred<SessionMeta>()
window.ycode.session.updateContext = async () => permissionSave.promise
const saving = useChatStore.getState().selectPermissionMode('full-access')
const sending = useChatStore.getState().send('after pending permission save')
assert.equal(sent, undefined, 'send waits for in-flight permission save')
permissionSave.resolve({ ...session('a'), permissionMode: 'full-access' })
await saving; await sending
assert.equal(sent?.permissionMode, 'full-access')
setup()
useChatStore.setState({ currentSessionId: 'a', sessions: [{ ...session('a'), permissionMode: 'full-access' }] })
window.ycode.session.updateContext = async (id, patch) => ({ ...useChatStore.getState().sessions.find(item => item.id === id)!, ...patch })
await useChatStore.getState().selectAgentMode('plan')
await assert.rejects(useChatStore.getState().selectPermissionMode('default-sandbox'), /不能修改权限/)
await useChatStore.getState().selectAgentMode('ask')
await assert.rejects(useChatStore.getState().selectPermissionMode('default-sandbox'), /不能修改权限/)
await useChatStore.getState().selectAgentMode('craft')
assert.equal(useChatStore.getState().sessions[0].permissionMode, 'full-access', 'plan/ask exit restores original choice')
setup()
useChatStore.setState({ currentSessionId: 'a', sessions: [{ ...session('a'), permissionMode: 'full-access' }] })
const rejectedPermission = deferred<SessionMeta>()
window.ycode.session.updateContext = async () => rejectedPermission.promise
const failingSave = useChatStore.getState().selectPermissionMode('default-sandbox')
const saveRejected = assert.rejects(failingSave, /cannot save downgrade/)
const unsafeSend = useChatStore.getState().send('must not execute with old full access')
const sendRejected = assert.rejects(unsafeSend, /cannot save downgrade/)
rejectedPermission.reject(new Error('cannot save downgrade'))
await saveRejected; await sendRejected
assert.equal(sent, undefined, 'failed pending permission downgrade must block send')
window.ycode.session.updateContext = async (id, patch) => ({ ...session(id), ...patch })
await useChatStore.getState().selectSession('b')
await useChatStore.getState().selectModel(model.id)
await useChatStore.getState().send('other owner remains usable')
assert.equal(sent?.sessionId, 'b')
await useChatStore.getState().selectSession('a')
await assert.rejects(useChatStore.getState().send('A still blocked after B saved'), /cannot save downgrade/)
// Automatic invalid-model repair is unrelated to a failed permission choice.
useChatStore.setState(state => ({ sessions: state.sessions.map(item => item.id === 'a' ? { ...item, modelId:'deleted' } : item) }))
await assert.rejects(useChatStore.getState().send('automatic model fallback must not clear permission failure'), /cannot save downgrade/)
assert.equal(useChatStore.getState().sessions.find(item => item.id === 'a')?.modelId, model.id)
await useChatStore.getState().selectAgentMode('plan')
await assert.rejects(useChatStore.getState().send('mode save must not clear permission failure'), /cannot save downgrade/)
await useChatStore.getState().selectAgentMode('craft')
await useChatStore.getState().selectPermissionMode('default-sandbox')
await useChatStore.getState().send('safe retry')
assert.equal(sent?.permissionMode, 'default-sandbox')
setup()
useChatStore.setState({ currentSessionId: 'a' })
const firstSave = deferred<SessionMeta>()
let contextCalls = 0
window.ycode.session.updateContext = async (id, patch) => {
contextCalls++
if (contextCalls === 1) return firstSave.promise
return { ...useChatStore.getState().sessions.find(item => item.id === id)!, ...patch }
}
const permissionFirst = useChatStore.getState().selectPermissionMode('full-access')
const modeSecond = useChatStore.getState().selectAgentMode('plan')
const afterBoth = useChatStore.getState().send('wait for permission and mode')
await Promise.resolve(); await Promise.resolve()
assert.equal(contextCalls, 1, 'mode save cannot overtake earlier permission write')
assert.equal(sent, undefined)
firstSave.resolve({ ...session('a'), permissionMode: 'full-access' })
await permissionFirst; await modeSecond; await afterBoth
assert.equal(contextCalls, 2)
assert.equal(sent?.permissionMode, 'full-access')
assert.equal(useChatStore.getState().sessions[0].agentMode, 'plan')
setup()
useChatStore.setState({ draftId: 50, draftContext: { modelId:model.id, permissionMode:'full-access' } })
const creation = deferred<SessionMeta>()
window.ycode.session.create = async () => creation.promise
const olderDraftSend = useChatStore.getState().send('old draft')
useChatStore.getState().startTask({ modelId:model.id, permissionMode:'default-sandbox' })
creation.resolve({ ...session('created-old-draft'), permissionMode:'full-access' })
await olderDraftSend
assert.equal(sent?.sessionId, 'created-old-draft')
assert.equal(sent?.permissionMode, 'full-access')
assert.equal(useChatStore.getState().currentSessionId, null)
assert.equal(useChatStore.getState().draftContext.permissionMode, 'default-sandbox', 'pending creation cannot overwrite newer draft preference')
assert.equal(renderToStaticMarkup(<ModeChip mode="craft" onClear={() => {}} />), '')
assert.match(renderToStaticMarkup(<ModeChip mode="plan" onClear={() => {}} />), /退出计划模式/)
assert.doesNotMatch(renderToStaticMarkup(<ModeChip mode="ask" disabled onClear={() => {}} />), /<button/)
const skeleton = renderToStaticMarkup(<SessionLoading loading />)
assert.equal((skeleton.match(/yc-session-skeleton__group/g) ?? []).length, 2)
assert.equal((skeleton.match(/yc-session-skeleton__line /g) ?? []).length, 6)
assert.equal(renderToStaticMarkup(<SessionLoading loading={false} />), '')
setup()
const history = Array.from({ length: 10000 }, (_, i) => message(`message-${i}`))
const json = JSON.stringify(history), before = performance.now()
const parsed = JSON.parse(json)
const parseMs = performance.now() - before
window.ycode.session.messages = async () => parsed
const start = performance.now(); await useChatStore.getState().selectSession('a')
assert.equal(useChatStore.getState().messages.length, 10000)
console.log(JSON.stringify({ historyMessages:10000, bytes:json.length, parseMs, storeHydrationMs:performance.now()-start, note:'Node parsing/store benchmark only, not browser rendering or Windows IPC performance' }))
console.log('session-composer-alignment: loading/cache/retry/races/model/permissions/modes/SSR passed')
}
void main()