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 = () => { let resolve!: (value: T) => void; let reject!: (reason: Error) => void; const promise = new Promise((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() 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() 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(), b: deferred(), c: deferred() } 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() 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() 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() 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() 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() 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( {}} />), '') assert.match(renderToStaticMarkup( {}} />), /退出计划模式/) assert.doesNotMatch(renderToStaticMarkup( {}} />), /