import assert from 'node:assert/strict' import { useChatStore } from '../src/renderer/src/stores/chat' import type { SendResult } from '../src/shared/ipc' import type { ChatEvent, ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types' const session: SessionMeta = { id: 'session-1', title: 'Regression', createdAt: 1, updatedAt: 1 } function tool(overrides: Partial = {}): ToolCallRecord { return { id: 'tool-1', name: 'read_file', args: { path: 'notes.txt' }, argsRaw: '{"path":"notes.txt"}', status: 'running', ...overrides } } function sendResult(runId: string): SendResult { return { runId, assistantMessageId: `${runId}-assistant`, modelId: 'model-profile-1', modelName: 'Frozen Model', userMessage: { id: `${runId}-user`, role: 'user', content: 'question', createdAt: 10 } } } function resetStore(): void { useChatStore.setState({ sessions: [session], currentSessionId: session.id, messages: [], sessionMessages: {}, runs: {}, sendingSessions: [], running: null, sending: false, loading: false, error: null }) } function installFakeWindow(send: (payload: { sessionId: string; text: string }) => Promise): void { const api = { session: { list: async () => [session], create: async () => session, messages: async () => [], rename: async () => undefined, remove: async () => undefined }, chat: { send, cancel: async () => ({ cancelled: true }), onEvent: () => () => undefined } } Object.defineProperty(globalThis, 'window', { configurable: true, value: { ycode: api } }) } function assistantMessage(): ChatMessage { const message = useChatStore.getState().messages.find((entry) => entry.role === 'assistant') assert.ok(message, 'send result should create an assistant placeholder') return message } function deferred(): { promise: Promise; resolve(value: T): void } { let resolve!: (value: T) => void const promise = new Promise((done) => { resolve = done }) return { promise, resolve } } async function testEarlyOrderedEventsAndDuplicateGuard(): Promise { resetStore() const result = sendResult('run-order') const response = deferred() let sendCalls = 0 const events: ChatEvent[] = [ { runId: 'other-run', type: 'text', delta: 'must be ignored' }, { runId: result.runId, type: 'text', delta: 'before' }, { runId: result.runId, type: 'tool_call', call: tool() }, { runId: result.runId, type: 'text', delta: 'after' }, { runId: result.runId, type: 'tool_result', call: tool({ status: 'ok', output: 'file contents', summary: 'file contents', durationMs: 12 }) }, { runId: result.runId, type: 'done', content: 'beforeafter', tools: [tool({ status: 'ok', output: 'file contents', summary: 'file contents', durationMs: 12 })] } ] installFakeWindow(async () => { sendCalls++ for (const event of events) useChatStore.getState().applyEvent(event) return response.promise }) const firstSend = useChatStore.getState().send('question') assert.equal(useChatStore.getState().sending, true, 'sending should cover the pending IPC request') assert.equal(useChatStore.getState().messages.length, 0, 'early events wait for the send result placeholder') await useChatStore.getState().send('duplicate') assert.equal(sendCalls, 1, 'a second send must be ignored while the first send is pending') response.resolve(result) await firstSend const message = assistantMessage() assert.equal(message.turnStartedAt, undefined, 'terminal events clear the live timer timestamp') assert.equal(message.content, 'beforeafter') assert.deepEqual(message.parts, [ { type: 'text', text: 'before' }, { type: 'tool', toolId: 'tool-1' }, { type: 'text', text: 'after' } ]) assert.equal(message.status, 'done') assert.equal(message.tools?.length, 1) assert.equal(message.tools?.[0].output, 'file contents') assert.equal(useChatStore.getState().running, null, 'done should clear the running turn') assert.equal(useChatStore.getState().sending, false, 'the send request should leave the sending state') assert.equal(message.content.includes('must be ignored'), false, 'events from another run must be ignored') useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'late' }) assert.equal(assistantMessage().content, 'beforeafter', 'events after a terminal event must not change the message') } async function testLiveTurnStartTimestamp(): Promise { resetStore() const result = sendResult('run-live-timer') installFakeWindow(async () => result) await useChatStore.getState().send('question') const message = assistantMessage() assert.equal(message.status, 'streaming') assert.equal(message.modelId, 'model-profile-1', 'assistant placeholder keeps the model identity from the send result') assert.equal(message.modelName, 'Frozen Model', 'assistant placeholder can render its model label before reloading history') assert.equal(message.turnStartedAt, result.userMessage.createdAt, 'the live timer starts from the same user-submit timestamp used by terminal duration') useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'answer' }) assert.equal(assistantMessage().turnStartedAt, result.userMessage.createdAt, 'ordinary stream updates keep the timer origin stable') useChatStore.getState().applyEvent({ runId: result.runId, type: 'done', content: 'answer', tools: [], turnDurationMs: 1200 }) assert.equal(assistantMessage().turnDurationMs, 1200) assert.equal(assistantMessage().turnStartedAt, undefined) } async function testEarlyTerminalEvents(): Promise { const cases: Array<{ name: string terminal: ChatEvent messageStatus: 'cancelled' | 'error' toolStatus: 'cancelled' | 'error' }> = [ { name: 'cancelled', terminal: { runId: 'run-cancel', type: 'cancelled' }, messageStatus: 'cancelled', toolStatus: 'cancelled' }, { name: 'error', terminal: { runId: 'run-error', type: 'error', message: 'provider failed' }, messageStatus: 'error', toolStatus: 'error' } ] for (const scenario of cases) { resetStore() const result = sendResult(scenario.terminal.runId) const response = deferred() installFakeWindow(async () => { useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'prefix' }) useChatStore.getState().applyEvent({ runId: result.runId, type: 'tool_call', call: tool({ output: 'partial output' }) }) useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'suffix' }) useChatStore.getState().applyEvent(scenario.terminal) return response.promise }) const sending = useChatStore.getState().send('question') response.resolve(result) await sending const message = assistantMessage() assert.equal(message.content, 'prefixsuffix', `${scenario.name}: text received before the result must be retained`) assert.deepEqual(message.parts, [ { type: 'text', text: 'prefix' }, { type: 'tool', toolId: 'tool-1' }, { type: 'text', text: 'suffix' } ], `${scenario.name}: ordered content should survive a terminal event`) assert.equal(message.status, scenario.messageStatus) assert.equal(message.tools?.[0].status, scenario.toolStatus) assert.equal(message.tools?.[0].output, 'partial output', `${scenario.name}: terminal state must preserve tool output`) assert.equal(useChatStore.getState().running, null) assert.equal(useChatStore.getState().sending, false) if (scenario.name === 'error') assert.equal(useChatStore.getState().error, 'provider failed') } } async function testFailedSendCanRetry(): Promise { resetStore() let sendCalls = 0 const retryResult = sendResult('run-retry') installFakeWindow(async () => { sendCalls++ if (sendCalls === 1) { useChatStore.getState().applyEvent({ runId: 'run-failed', type: 'text', delta: 'discarded failed run' }) throw new Error('send failed') } useChatStore.getState().applyEvent({ runId: retryResult.runId, type: 'done', content: 'retry complete', tools: [] }) return retryResult }) await assert.rejects(useChatStore.getState().send('question'), /send failed/) assert.equal(useChatStore.getState().sending, false, 'a rejected send must reset sending') assert.equal(useChatStore.getState().error, 'send failed') assert.equal(useChatStore.getState().messages.length, 0, 'a failed send must not install a placeholder') await useChatStore.getState().send('retry') assert.equal(sendCalls, 2, 'a new send should be accepted after a failed request') const message = assistantMessage() assert.equal(message.content, 'retry complete', 'failed-run buffered events must not leak into a retry') assert.equal(message.status, 'done') assert.equal(useChatStore.getState().sending, false) assert.equal(useChatStore.getState().running, null) } async function testCancelFlushesPendingAndIgnoresLateEvents(): Promise { resetStore() const result = sendResult('run-user-cancel') installFakeWindow(async () => result) let cancelCalls = 0 window.ycode.chat.cancel = async (runId) => { cancelCalls++ assert.equal(runId, result.runId) return { cancelled: true } } await useChatStore.getState().send('question') useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'already rendered' }) useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: ' pending text' }) await useChatStore.getState().cancel() assert.equal(cancelCalls, 1) assert.equal(assistantMessage().content, 'already rendered pending text', 'cancel must flush pending text before requesting cancellation') useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'late before terminal' }) assert.equal(assistantMessage().content, 'already rendered pending text', 'text after a cancel request must be ignored') useChatStore.getState().applyEvent({ runId: result.runId, type: 'cancelled' }) useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'late after terminal' }) assert.equal(assistantMessage().content, 'already rendered pending text') assert.equal(assistantMessage().status, 'cancelled') } async function testSessionSwitchFlushesPendingIntoItsOwner(): Promise { resetStore() const result = sendResult('run-session-switch') installFakeWindow(async () => result) await useChatStore.getState().send('question') useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: 'session one' }) useChatStore.getState().applyEvent({ runId: result.runId, type: 'text', delta: ' pending' }) await useChatStore.getState().selectSession('session-2') assert.equal(useChatStore.getState().messages.length, 0, 'switching sessions must not show the outgoing assistant message') assert.equal(useChatStore.getState().sessionMessages[session.id]?.find((message) => message.id === result.assistantMessageId)?.content, 'session one pending', 'switching must flush buffered text into the session that owns the run') await useChatStore.getState().selectSession(session.id) assert.equal(assistantMessage().content, 'session one pending', 'restoring the session must preserve the flushed stream order') useChatStore.getState().applyEvent({ runId: result.runId, type: 'done', content: 'session one pending', tools: [] }) } async function testParallelSessionsAndStaleLoads(): Promise { resetStore() const a = deferred() const b = deferred() installFakeWindow(async (payload) => payload.sessionId === session.id ? a.promise : b.promise) const first = useChatStore.getState().send('A') await useChatStore.getState().selectSession('session-2') assert.equal(useChatStore.getState().sending, false) const second = useChatStore.getState().send('B') useChatStore.getState().applyEvent({ sessionId: session.id, runId: 'a', type: 'text', delta: 'early A' }) useChatStore.getState().applyEvent({ sessionId: 'session-2', runId: 'b', type: 'text', delta: 'early B' }) b.resolve(sendResult('b')) await second a.resolve(sendResult('a')) await first assert.equal(assistantMessage().content, 'early B', 'a background send cannot replace the foreground') assert.equal(useChatStore.getState().running?.runId, 'b') useChatStore.getState().applyEvent({ sessionId: session.id, runId: 'a', type: 'done', content: 'finished A', tools: [] }) assert.equal(useChatStore.getState().running?.runId, 'b', 'background terminal cannot stop foreground run') await useChatStore.getState().selectSession(session.id) assert.equal(assistantMessage().content, 'finished A', 'switch reads current in-memory data') assert.equal(useChatStore.getState().running, null) await useChatStore.getState().selectSession('session-2') assert.equal(useChatStore.getState().running?.runId, 'b') useChatStore.getState().applyEvent({ runId: 'b', type: 'cancelled' }) const slow = deferred() window.ycode.session.messages = async (id) => id === 'slow' ? slow.promise : [{ id: 'fast-user', role: 'user', content: 'fast', createdAt: 1 }] const oldSelection = useChatStore.getState().selectSession('slow') await useChatStore.getState().selectSession('fast') slow.resolve([{ id: 'old', role: 'user', content: 'slow', createdAt: 1 }]) await oldSelection assert.equal(useChatStore.getState().currentSessionId, 'fast') assert.equal(useChatStore.getState().messages[0].content, 'fast', 'stale session fetch cannot replace a newer selection') } async function main(): Promise { await testEarlyOrderedEventsAndDuplicateGuard() await testLiveTurnStartTimestamp() await testEarlyTerminalEvents() await testFailedSendCanRetry() await testCancelFlushesPendingAndIgnoresLateEvents() await testSessionSwitchFlushesPendingIntoItsOwner() await testParallelSessionsAndStaleLoads() resetStore() installFakeWindow(async () => sendResult('unused')) const creation = deferred() window.ycode.session.create = async () => creation.promise const creating = useChatStore.getState().newSession() await useChatStore.getState().selectSession('chosen-later') creation.resolve({ ...session, id: 'slow-created' }) await creating assert.equal(useChatStore.getState().currentSessionId, 'chosen-later', 'late session creation cannot override a later selection') resetStore() useChatStore.setState({ currentSessionId: null }) let sentTo = '' installFakeWindow(async (payload) => { sentTo = payload.sessionId; return sendResult('owned-create') }) const ownedCreation = deferred() window.ycode.session.create = async () => ownedCreation.promise const ownedSend = useChatStore.getState().send('original draft') await useChatStore.getState().selectSession('other-session') ownedCreation.resolve({ ...session, id: 'owned-session' }) await ownedSend assert.equal(sentTo, 'owned-session', 'pending creation keeps the send bound to its own session') assert.equal(useChatStore.getState().currentSessionId, 'other-session') resetStore() installFakeWindow(async () => sendResult('unused')) window.ycode.session.list = async () => { throw new Error('corrupt session store') } await useChatStore.getState().init() assert.equal(useChatStore.getState().error, 'corrupt session store', 'load failure is visible instead of silently creating an empty session') console.log('chat-store-regression: all checks passed') } void main()