feat: align conversation state, composer and scoped approvals
This commit is contained in:
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()
|
||||
Reference in new issue
Block a user