import assert from 'node:assert/strict' import { nextRequestSettings } from '../src/main/agent/request-settings' import { useChatStore } from '../src/renderer/src/stores/chat' import type { ActiveChatRun, SendPayload, SendResult } from '../src/shared/ipc' import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta } from '../src/shared/types' import type { SessionContext } from '../src/shared/workspaces' interface Deferred { promise: Promise resolve(value: T): void reject(reason?: unknown): void } interface FakeBackend { api: FakeApi sessions: SessionMeta[] messages: Record replaceSession(session: SessionMeta): void } interface FakeApi { session: { list(): Promise create(workspaceId: string | undefined, context?: SessionContext): Promise messages(sessionId: string): Promise updateContext(sessionId: string, patch: SessionContext): Promise remove(sessionId: string, options?: { onlyIfEmpty?: boolean }): Promise rename(sessionId: string, title: string): Promise } chat: { send(payload: SendPayload): Promise setSessionVisible(sessionId: string | null): Promise<{ accepted: boolean }> cancel(runId: string): Promise<{ cancelled: boolean }> revert(sessionId: string, messageId: string, options?: unknown): Promise<{ messages: ChatMessage[] }> respond(runId: string, interactionId: string, response: unknown): Promise<{ accepted: boolean }> activeRun(sessionId: string): Promise onEvent(listener: (event: ChatEvent) => void): () => void onStarted(listener: (run: ActiveChatRun) => void): () => void queue: { get(sessionId: string): Promise<{ items: unknown[] }> enqueue(payload: SendPayload): Promise } } } function deferred(): Deferred { let resolve!: (value: T) => void let reject!: (reason?: unknown) => void const promise = new Promise((accept, decline) => { resolve = accept reject = decline }) return { promise, resolve, reject } } async function flushAsync(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } async function waitFor(description: string, predicate: () => boolean, timeoutMs = 2_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (predicate()) return await new Promise((resolve) => setTimeout(resolve, 5)) } throw new Error(`timed out waiting for ${description}`) } function makeSession(id: string, overrides: Partial = {}): SessionMeta { return { id, title: id, createdAt: 1, updatedAt: 1, ...overrides } } function makeMessage(id: string, role: ChatMessage['role'], content: string): ChatMessage { return { id, role, content, createdAt: 1, status: 'done', ...(role === 'assistant' ? { tools: [], parts: [] } : {}) } } function makeSendResult( runId: string, modelId: string, modelName: string, contextUsage?: ContextUsageSnapshot ): SendResult { const userMessage = makeMessage(`${runId}-user`, 'user', 'question') const assistant: ChatMessage = { id: `${runId}-assistant`, role: 'assistant', content: '', tools: [], parts: [], requestId: runId, modelId, modelName, contextUsage, createdAt: 2, turnStartedAt: userMessage.createdAt, status: 'streaming' } return { runId, assistantMessageId: assistant.id, modelId, modelName, userMessage, messages: [userMessage, assistant] } } function installFakeBackend( sessionSeeds: SessionMeta[], messageSeeds: Record = {} ): FakeBackend { const sessions = sessionSeeds.map((item) => ({ ...item })) const messages = Object.fromEntries(Object.entries(messageSeeds).map(([id, items]) => [id, [...items]])) let createdSequence = 0 const api: FakeApi = { session: { list: async () => sessions.map((item) => ({ ...item })), create: async (_workspaceId, context = {}) => { const created = makeSession(`created-${++createdSequence}`, context) sessions.unshift(created) messages[created.id] = [] return { ...created } }, messages: async (sessionId) => [...(messages[sessionId] ?? [])], updateContext: async (sessionId, patch) => { const index = sessions.findIndex((item) => item.id === sessionId) if (index < 0) throw new Error(`unknown session ${sessionId}`) const updated = { ...sessions[index], ...patch } sessions[index] = updated return { ...updated } }, remove: async (sessionId, options) => { const index = sessions.findIndex((item) => item.id === sessionId) if (index < 0) return if (options?.onlyIfEmpty && (messages[sessionId]?.length ?? 0) > 0) throw new Error('session is not empty') sessions.splice(index, 1) delete messages[sessionId] }, rename: async (sessionId, title) => { const index = sessions.findIndex((item) => item.id === sessionId) if (index >= 0) sessions[index] = { ...sessions[index], title } } }, chat: { send: async () => { throw new Error('fake send was not configured') }, setSessionVisible: async () => ({ accepted: true }), cancel: async () => ({ cancelled: true }), revert: async () => ({ messages: [] }), respond: async () => ({ accepted: true }), activeRun: async () => null, onEvent: () => () => undefined, onStarted: () => () => undefined, queue: { get: async () => ({ items: [] }), enqueue: async () => undefined } } } Object.defineProperty(globalThis, 'window', { configurable: true, value: { ycode: api } }) return { api, sessions, messages, replaceSession(session) { const index = sessions.findIndex((item) => item.id === session.id) if (index >= 0) sessions[index] = { ...session } else sessions.unshift({ ...session }) } } } function resetStore( sessions: SessionMeta[], currentSessionId: string | null, messages: ChatMessage[] = [], draftContext: SessionContext = {} ): void { useChatStore.setState({ sessions, currentSessionId, messages, sessionMessages: currentSessionId ? { [currentSessionId]: messages } : {}, sessionUsage: {}, sessionContextWindow: {}, runs: {}, runSequences: {}, sendingSessions: [], running: null, sending: false, loading: false, error: null, draftContext, draftId: 0 }) } async function testSerialModelWritesAndFrozenRunningTurn(): Promise { const main = makeSession('main', { modelId: 'model-a', thinkingLevel: 'low' }) resetStore([main], main.id) const backend = installFakeBackend([main], { [main.id]: [] }) const frozenUsage: ContextUsageSnapshot = { usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 }, contextWindow: 64_000, sampledAt: 10, requestId: 'run-a', modelId: 'model-a' } let sentPayload: SendPayload | undefined backend.api.chat.send = async (payload) => { sentPayload = payload return makeSendResult('run-a', 'model-a', 'Model A', frozenUsage) } await useChatStore.getState().send('freeze current run') assert.equal(sentPayload?.sessionId, main.id) assert.equal(useChatStore.getState().running?.runId, 'run-a') const writes: Array<{ id: string; patch: SessionContext; gate: Deferred }> = [] backend.api.session.updateContext = (sessionId, patch) => { const gate = deferred() writes.push({ id: sessionId, patch, gate }) return gate.promise } const resolveWrite = (index: number): void => { const write = writes[index] const current = backend.sessions.find((item) => item.id === write.id) ?? makeSession(write.id) const updated = { ...current, ...write.patch } backend.replaceSession(updated) write.gate.resolve(updated) } const pendingA = useChatStore.getState().selectModel('model-a') await waitFor('first serialized model write', () => writes.length === 1) const pendingB = useChatStore.getState().selectModel('model-b') const pendingC = useChatStore.getState().selectModel('model-c') await flushAsync() assert.equal(writes.length, 1, 'model writes must be serialized instead of racing') resolveWrite(0) await waitFor('second serialized model write', () => writes.length === 2) resolveWrite(1) await waitFor('third serialized model write', () => writes.length === 3) resolveWrite(2) await Promise.all([pendingA, pendingB, pendingC]) assert.deepEqual(writes.map((write) => write.patch.modelId), ['model-a', 'model-b', 'model-c']) const state = useChatStore.getState() assert.equal(state.sessions.find((item) => item.id === main.id)?.modelId, 'model-c') const assistant = state.messages.find((message) => message.id === 'run-a-assistant') assert.ok(assistant) assert.equal(assistant.modelId, 'model-a', 'the active assistant must keep its starting model') assert.equal(assistant.modelName, 'Model A') assert.deepEqual(assistant.contextUsage?.usage, frozenUsage.usage) assert.equal(assistant.contextUsage?.contextWindow, 64_000) assert.deepEqual(state.sessionUsage[main.id], frozenUsage.usage) assert.equal(state.sessionContextWindow[main.id], 64_000) } async function testSendFailureDoesNotPoisonRetry(): Promise { const main = makeSession('retry-session', { modelId: 'model-a' }) resetStore([main], main.id) const backend = installFakeBackend([main], { [main.id]: [] }) let calls = 0 backend.api.chat.send = async () => { calls++ if (calls === 1) throw new Error('mock send failure') return makeSendResult('run-retry', 'model-a', 'Model A') } const fakeWindow = (globalThis as typeof globalThis & { window: { ycode: Record } }).window assert.equal('models' in fakeWindow.ycode, false, 'the fake window must not expose paid model APIs') assert.equal('providers' in fakeWindow.ycode, false, 'the fake window must not expose provider APIs') await assert.rejects(useChatStore.getState().send('first attempt'), /mock send failure/) let state = useChatStore.getState() assert.equal(state.sending, false) assert.deepEqual(state.sendingSessions, []) assert.deepEqual(state.messages, []) assert.equal(state.error, 'mock send failure') await useChatStore.getState().send('retry attempt') state = useChatStore.getState() assert.equal(calls, 2, 'the retry must reach the send boundary exactly once') assert.equal(state.error, null) assert.equal(state.running?.runId, 'run-retry') assert.equal(state.messages.some((message) => message.id === 'run-retry-assistant'), true) } async function testLateResultAndEventStayInOriginalSession(): Promise { const first = makeSession('session-a', { modelId: 'model-a' }) const second = makeSession('session-b', { modelId: 'model-b' }) resetStore([first, second], first.id) const secondMessage = makeMessage('session-b-history', 'user', 'keep this session') const backend = installFakeBackend([first, second], { [first.id]: [], [second.id]: [secondMessage] }) const response = deferred() let sendStarted = false backend.api.chat.send = async () => { sendStarted = true return response.promise } const sending = useChatStore.getState().send('slow send') await waitFor('slow send to reach IPC', () => sendStarted) await useChatStore.getState().selectSession(second.id) assert.equal(useChatStore.getState().currentSessionId, second.id) assert.deepEqual(useChatStore.getState().messages, [secondMessage]) response.resolve(makeSendResult('late-run', 'model-a', 'Model A')) await sending let state = useChatStore.getState() assert.equal(state.currentSessionId, second.id, 'a late send response must not switch sessions') assert.deepEqual(state.messages, [secondMessage], 'a late send response must not replace the selected session messages') assert.equal(state.running, null, 'the original session run must not become the selected session run') assert.equal(state.runs['late-run']?.sessionId, first.id) assert.equal(state.sessionMessages[first.id]?.some((message) => message.id === 'late-run-assistant'), true) useChatStore.getState().applyEvent({ runId: 'late-run', sessionId: first.id, type: 'text', delta: 'late text', sequence: 1 }) useChatStore.getState().applyEvent({ runId: 'late-run', sessionId: first.id, type: 'done', content: 'late text', tools: [], sequence: 2 }) await waitFor('late terminal event to commit', () => { const assistant = useChatStore.getState().sessionMessages[first.id]?.find((message) => message.id === 'late-run-assistant') return assistant?.content === 'late text' && assistant.status === 'done' }) state = useChatStore.getState() assert.equal(state.currentSessionId, second.id) assert.deepEqual(state.messages, [secondMessage], 'late stream events must not leak into the selected session') const originalAssistant = state.sessionMessages[first.id]?.find((message) => message.id === 'late-run-assistant') assert.equal(originalAssistant?.content, 'late text') assert.equal(originalAssistant?.status, 'done') } async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise { resetStore([], null, [], { modelId: 'draft-a', thinkingLevel: 'low' }) const backend = installFakeBackend([], {}) const createGate = deferred() const updateGate = deferred() let createContext: SessionContext | undefined let updateCapture: { id: string; patch: SessionContext } | undefined let sendCalls = 0 backend.api.session.create = async (_workspaceId, context) => { createContext = context ? { ...context } : undefined return createGate.promise } backend.api.session.updateContext = async (sessionId, patch) => { updateCapture = { id: sessionId, patch: { ...patch } } return updateGate.promise } backend.api.chat.send = async () => { sendCalls++ return makeSendResult('run-first', 'draft-b', 'Draft B') } const sending = useChatStore.getState().send('first message') await waitFor('first session creation', () => createContext !== undefined) assert.deepEqual(createContext, { modelId: 'draft-a', thinkingLevel: 'low' }) await useChatStore.getState().selectModel('draft-b') await useChatStore.getState().selectThinkingLevel('high') assert.deepEqual(useChatStore.getState().draftContext, { modelId: 'draft-b', thinkingLevel: 'high' }) const created = makeSession('created-session', { modelId: 'draft-a', thinkingLevel: 'low' }) backend.replaceSession(created) createGate.resolve(created) await waitFor('draft context patch after session creation', () => updateCapture !== undefined) assert.deepEqual(updateCapture, { id: 'created-session', patch: { modelId: 'draft-b', thinkingLevel: 'high' } }) assert.equal(sendCalls, 0, 'the first send must wait for the newer draft settings to persist') const updated = { ...created, ...updateCapture!.patch } backend.replaceSession(updated) updateGate.resolve(updated) await sending const state = useChatStore.getState() 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')?.thinkingLevel, 'high') assert.deepEqual(state.draftContext, { modelId: 'draft-b', thinkingLevel: 'high' }) assert.equal(sendCalls, 1) } function testNextRequestSettingsWhitelist(): void { assert.deepEqual(nextRequestSettings({ modelId: 'model-next', thinkingLevel: 'high' }), { modelId: 'model-next', thinkingLevel: 'high' }) assert.deepEqual(nextRequestSettings({}), {}) const forbidden: SessionContext[] = [ { permissionMode: 'full-access' }, { cwd: 'C:\\workspace' }, { agentMode: 'plan' }, { projectId: 'project-1' }, { workspaceId: 'workspace-1' }, { expertId: 'expert-1' }, { modelId: 'model-next', permissionMode: 'default-sandbox' } ] for (const patch of forbidden) { assert.throws(() => nextRequestSettings(patch), /运行期间只能修改下次发送的模型和思考强度/) } assert.throws(() => nextRequestSettings({ modelId: ' ' }), /模型标识无效/) assert.throws(() => nextRequestSettings({ thinkingLevel: 'invalid' as never }), /无效的思考强度/) } const originalFetch = globalThis.fetch globalThis.fetch = (async () => { throw new Error('network access is forbidden in request-settings regression') }) as typeof fetch const completed: string[] = [] try { await testSerialModelWritesAndFrozenRunningTurn() completed.push('serial model writes + frozen active turn') await testSendFailureDoesNotPoisonRetry() completed.push('send failure retry') await testLateResultAndEventStayInOriginalSession() completed.push('late result/event session isolation') await testDraftContextWinsWhileFirstSessionIsCreated() completed.push('draft settings during first session creation') testNextRequestSettingsWhitelist() completed.push('nextRequestSettings whitelist') console.log(JSON.stringify({ result: 'PASS', tests: completed }, null, 2)) } finally { globalThis.fetch = originalFetch }