236 lines
14 KiB
TypeScript
236 lines
14 KiB
TypeScript
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, 'deleted', 'failed settings block automatic repair as well as submission')
|
|
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')
|
|
|
|
for (const initialModel of [undefined, 'deleted']) {
|
|
for (const queued of [false, true]) {
|
|
setup()
|
|
const owner = `pending-model-${initialModel}-${queued}`
|
|
const chosen = { ...model, id: 'chosen', name: 'Chosen' }
|
|
useSettingsStore.setState({ models: [model, chosen], selectedModelId: model.id })
|
|
useChatStore.setState({ currentSessionId: owner, sessions: [{ ...session(owner), modelId: initialModel }],
|
|
...(queued ? { runs: { existing: { runId: 'existing', assistantId: 'assistant', sessionId: owner } } } : {}) })
|
|
const choice = deferred<SessionMeta>()
|
|
const writes: Array<{ id: string; modelId?: string }> = []
|
|
window.ycode.session.updateContext = async (id, patch) => {
|
|
writes.push({ id, modelId: patch.modelId })
|
|
if (writes.length === 1) return choice.promise
|
|
return { ...session(id), ...patch }
|
|
}
|
|
if (queued) window.ycode.chat.queue = { enqueue: async (payload: SendPayload) => { sent = payload; return {} } } as any
|
|
const choosing = useChatStore.getState().selectModel(chosen.id)
|
|
const nextSend = useChatStore.getState().send('explicit pending choice wins')
|
|
assert.equal(sent, undefined)
|
|
// Switching views while the save is pending must not repair the new owner.
|
|
useChatStore.setState({ currentSessionId: 'b' })
|
|
choice.resolve({ ...session(owner), modelId: chosen.id })
|
|
await choosing; await nextSend
|
|
assert.deepEqual(writes, [{ id: owner, modelId: chosen.id }], 'fallback cannot overwrite a pending explicit choice')
|
|
assert.equal(sent?.sessionId, owner)
|
|
assert.equal(useChatStore.getState().sessions.find(item => item.id === owner)?.modelId, chosen.id)
|
|
}
|
|
}
|
|
|
|
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()
|