import assert from 'node:assert/strict' import { useChatStore } from '../src/renderer/src/stores/chat' import type { ActiveChatRun, SendResult } from '../src/shared/ipc' import type { ChatEvent, ChatMessage, ContextUsageSnapshot, 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 activeRunSnapshot( runId: string, content = 'snapshot', sequence = 5, sessionId = session.id, contextUsage?: ContextUsageSnapshot ): ActiveChatRun { const userMessage: ChatMessage = { id: `${runId}-user`, role: 'user', content: 'question', requestId: runId, createdAt: 10, status: 'done' } const assistant: ChatMessage = { id: `${runId}-assistant`, role: 'assistant', content, requestId: runId, modelId: 'model-profile-1', modelName: 'Frozen Model', contextUsage, tools: [], parts: content ? [{ type: 'text', text: content }] : [], createdAt: 11, turnStartedAt: 10, status: 'streaming' } return { runId, assistantMessageId: assistant.id, modelId: 'model-profile-1', modelName: 'Frozen Model', userMessage, messages: [userMessage, assistant], sessionId, sequence } } function resetStore(): void { useChatStore.setState({ sessions: [session], currentSessionId: session.id, messages: [], sessionMessages: {}, sessionUsage: {}, sessionContextWindow: {}, runs: {}, runSequences: {}, sendingSessions: [], running: null, sending: false, loading: false, error: null, draftContext: {}, draftId: 0 }) } function installFakeWindow(send: (payload: { sessionId: string; text: string }) => Promise): void { const api = { session: { list: async () => [session], create: async () => session, messages: async (id: string) => useChatStore.getState().sessionMessages[id] ?? [], rename: async () => undefined, remove: async () => undefined, updateContext: async (_id: string, patch: object) => ({ ...session, ...patch }) }, chat: { send, cancel: async () => ({ cancelled: true }), revert: async () => ({ messages: [] }), activeRun: async (sessionId: string) => { const state = useChatStore.getState() const run = Object.values(state.runs).find((item) => item.sessionId === sessionId) if (!run) return null const messages = state.sessionMessages[sessionId] ?? (state.currentSessionId === sessionId ? state.messages : []) const userMessage = messages.find((message) => message.role === 'user') const assistant = messages.find((message) => message.id === run.assistantId) if (!userMessage || !assistant) return null return { runId: run.runId, assistantMessageId: run.assistantId, modelId: assistant.modelId, modelName: assistant.modelName, assistantDisplay: assistant.assistantDisplay, userMessage, messages, sessionId, sequence: state.runSequences[run.runId] ?? 0 } }, onEvent: () => () => undefined, onStarted: () => () => 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; reject(reason?: unknown): void } { let resolve!: (value: T) => void let reject!: (reason?: unknown) => void const promise = new Promise((done, fail) => { resolve = done reject = fail }) return { promise, resolve, reject } } async function testFailedCreatedSessionIsRemoved(): Promise { resetStore() const created = { ...session, id: 'created-empty', title: '' } const switched = deferred() const sendRejected = deferred() let removeCalls = 0 installFakeWindow(async () => { switched.resolve() return sendRejected.promise }) window.ycode.session.create = async () => created window.ycode.session.list = async () => [created] window.ycode.session.messages = async () => [] window.ycode.session.remove = async (id, options) => { assert.equal(id, created.id) assert.deepEqual(options, { onlyIfEmpty: true }, 'automatic cleanup must use the atomic empty-session delete') removeCalls++ } useChatStore.setState({ currentSessionId: null, draftId: 7, draftContext: { workspaceId: 'space-draft' } }) const sending = useChatStore.getState().send('first draft') await switched.promise sendRejected.reject(new Error('send rejected after create')) await assert.rejects(sending, /send rejected after create/) const state = useChatStore.getState() assert.equal(removeCalls, 1, 'an unstarted, message-free session created for this send must be deleted') assert.equal(state.currentSessionId, null, 'the UI must return to a real new-task state') assert.deepEqual(state.messages, []) assert.equal(created.id in state.sessionMessages, false) assert.equal(created.id in state.sessionUsage, false) assert.equal(created.id in state.sessionContextWindow, false) assert.equal(state.sessions.some((item) => item.id === created.id), false) assert.equal(state.draftId, 7, 'failure recovery must preserve the draft owner id') assert.deepEqual(state.draftContext, { workspaceId: 'space-draft' }, 'failure recovery must preserve draft context') assert.equal(state.sending, false) assert.match(state.error ?? '', /send rejected after create/) } async function testFailedCreatedSessionWithMessagesIsRetained(): Promise { resetStore() const created = { ...session, id: 'created-with-message', title: 'Persisted' } const persisted: ChatMessage = { id: 'persisted-user', role: 'user', content: 'persisted prompt', createdAt: 20, status: 'done' } let removeCalls = 0 installFakeWindow(async () => { throw new Error('start turn failed') }) window.ycode.session.create = async () => created window.ycode.session.list = async () => [created] window.ycode.session.messages = async () => [persisted] window.ycode.session.remove = async () => { removeCalls++ } useChatStore.setState({ currentSessionId: null, draftId: 8 }) await assert.rejects(useChatStore.getState().send('must persist'), /start turn failed/) const state = useChatStore.getState() assert.equal(removeCalls, 0, 'a session with persisted messages must never be deleted') assert.equal(state.currentSessionId, created.id, 'the real failed session must stay selected') assert.deepEqual(state.messages, [persisted]) assert.equal(state.sessions.some((item) => item.id === created.id), true) assert.equal(state.sending, false) assert.match(state.error ?? '', /start turn failed.*已有持久化消息/) } async function testUnsafeCleanupDoesNotMasqueradeAsHome(): Promise { resetStore() const created = { ...session, id: 'created-cleanup-failed', title: '' } installFakeWindow(async () => { throw new Error('runtime unavailable') }) window.ycode.session.create = async () => created window.ycode.session.list = async () => [created] window.ycode.session.messages = async () => [] window.ycode.session.remove = async () => { throw new Error('remove denied') } useChatStore.setState({ currentSessionId: null, draftId: 9 }) await assert.rejects(useChatStore.getState().send('keep failure visible'), /runtime unavailable/) const state = useChatStore.getState() assert.equal(state.currentSessionId, created.id, 'failed cleanup must retain the real session') assert.equal(state.sessions.some((item) => item.id === created.id), true) assert.equal(state.messages.length, 2, 'the failed user/assistant pair must remain visible') assert.equal(state.messages[1].status, 'error') assert.match(state.error ?? '', /空会话清理失败.*remove denied/) } async function testBufferedBackgroundEventPreventsDeletion(): Promise { resetStore() const created = { ...session, id: 'created-with-early-event', title: '' } let removeCalls = 0 installFakeWindow(async () => { useChatStore.getState().applyEvent({ sessionId: created.id, runId: 'background-before-response', type: 'text', delta: 'background started' }) throw new Error('response lost') }) window.ycode.session.create = async () => created window.ycode.session.list = async () => [created] window.ycode.session.messages = async () => [] window.ycode.session.remove = async () => { removeCalls++ } useChatStore.setState({ currentSessionId: null, draftId: 11 }) await assert.rejects(useChatStore.getState().send('race event'), /response lost/) const state = useChatStore.getState() assert.equal(removeCalls, 0, 'an early run event proves deletion is unsafe') assert.equal(state.currentSessionId, created.id) assert.equal(state.sessions.some((item) => item.id === created.id), true) assert.equal(state.messages.length, 2, 'the retained failed send remains visible') assert.match(state.error ?? '', /检测到后台回合/) } async function testLateSendFailureCannotOverrideAnotherTask(): Promise { resetStore() const created = { ...session, id: 'created-race', title: '' } const sendStarted = deferred() const sendResponse = deferred() let removeCalls = 0 installFakeWindow(async () => { sendStarted.resolve() return sendResponse.promise }) window.ycode.session.create = async () => created window.ycode.session.list = async () => [created] window.ycode.session.messages = async () => [] window.ycode.session.remove = async () => { removeCalls++ } useChatStore.setState({ currentSessionId: null, draftId: 10 }) const sending = useChatStore.getState().send('race prompt') await sendStarted.promise useChatStore.getState().startTask({ projectId: 'newer-task' }) const switchedDraftId = useChatStore.getState().draftId sendResponse.reject(new Error('late failure')) await assert.rejects(sending, /late failure/) const state = useChatStore.getState() assert.equal(removeCalls, 1) assert.equal(state.currentSessionId, null, 'a late failure must not replace the newer task selection') assert.equal(state.draftId, switchedDraftId, 'a late failure must not advance or rewind the draft owner') assert.deepEqual(state.draftContext, { projectId: 'newer-task' }) assert.equal(state.sessions.some((item) => item.id === created.id), false) } async function testModelChangePreservesFrozenUsage(): Promise { resetStore() const used = { inputTokens: 120, outputTokens: 30, totalTokens: 150 } const stale = { inputTokens: 900, outputTokens: 50, totalTokens: 950 } useChatStore.setState({ messages: [{ id: 'model-history-assistant', role: 'assistant', content: 'history', createdAt: 1, contextUsage: { usage: used, contextWindow: 64_000 } }], sessionUsage: { [session.id]: stale }, sessionContextWindow: { [session.id]: 128_000 } }) installFakeWindow(async () => sendResult('unused')) window.ycode.session.updateContext = async (_id, patch) => ({ ...session, ...patch }) await useChatStore.getState().selectModel('model-next') assert.deepEqual(useChatStore.getState().sessionUsage[session.id], used, 'changing the selected model must restore the latest sample from retained history') assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000, 'changing the selected model must retain that sample’s frozen denominator') useChatStore.setState({ sessionUsage: { [session.id]: used } }) window.ycode.session.updateContext = async () => { throw new Error('model update failed') } await assert.rejects(useChatStore.getState().selectModel('model-rejected'), /model update failed/) assert.deepEqual(useChatStore.getState().sessionUsage[session.id], used, 'a rejected model change must leave the existing usage sample intact') assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000) } async function testRevertRestoresBranchUsage(): Promise { resetStore() let received: { sessionId: string; messageId: string; options: unknown } | null = null installFakeWindow(async () => sendResult('unused')) window.ycode.chat.revert = async (sessionId, messageId, options) => { received = { sessionId, messageId, options } return { messages: [{ id: 'kept-assistant', role: 'assistant', content: 'kept', createdAt: 1, contextUsage: { usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 }, contextWindow: 64_000 } }] } } useChatStore.setState({ sessionUsage: { [session.id]: { inputTokens: 900, outputTokens: 50, totalTokens: 950 } }, sessionContextWindow: { [session.id]: 128_000 } }) await useChatStore.getState().revert('user-message', { rollbackArtifacts: false }) assert.deepEqual(received, { sessionId: session.id, messageId: 'user-message', options: { rollbackArtifacts: false } }, 'revert options must be forwarded without changing the default artifact behavior') assert.deepEqual(useChatStore.getState().sessionUsage[session.id], { inputTokens: 120, outputTokens: 30, totalTokens: 150 }, 'rewind must restore the last real usage sample on the retained branch') assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000, 'rewind must restore the denominator frozen into that sample') window.ycode.chat.revert = async () => ({ messages: [] }) await useChatStore.getState().revert('older-message') assert.equal(session.id in useChatStore.getState().sessionUsage, false, 'rewind to a branch without usage must clear the abandoned sample') assert.equal(session.id in useChatStore.getState().sessionContextWindow, false) } async function testStoredUsageRestoresPerSessionAndKeepsLegacyUnknown(): Promise { resetStore() installFakeWindow(async () => sendResult('unused')) const branchA: ChatMessage[] = [ { id: 'a-user-1', role: 'user', content: 'a1', createdAt: 1 }, { id: 'a-assistant-1', role: 'assistant', content: 'a1', createdAt: 2, contextUsage: { usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 }, contextWindow: 64_000 } }, { id: 'a-user-2', role: 'user', content: 'a2', createdAt: 3 }, { id: 'a-streaming', role: 'assistant', content: '', createdAt: 4, status: 'streaming' } ] const branchB: ChatMessage[] = [{ id: 'b-assistant', role: 'assistant', content: 'b', createdAt: 5, contextUsage: { usage: { inputTokens: 300, outputTokens: 40, totalTokens: 340 }, contextWindow: 128_000 } }] const legacy: ChatMessage[] = [{ id: 'legacy-assistant', role: 'assistant', content: 'legacy', createdAt: 6, contextUsage: { usage: { inputTokens: 90, outputTokens: 10, totalTokens: 100 } } }] window.ycode.session.messages = async (id) => id === 'session-a' ? branchA : id === 'session-b' ? branchB : legacy await useChatStore.getState().selectSession('session-a') assert.equal(useChatStore.getState().sessionUsage['session-a'],undefined, 'a new assistant without a sample must not masquerade as the previous request') assert.equal(useChatStore.getState().sessionContextWindow['session-a'],undefined) await useChatStore.getState().selectSession('session-b') assert.deepEqual(useChatStore.getState().sessionUsage['session-b'], { inputTokens: 300, outputTokens: 40, totalTokens: 340 }) assert.equal(useChatStore.getState().sessionContextWindow['session-b'], 128_000) assert.equal(useChatStore.getState().sessionContextWindow['session-a'], undefined, 'selecting another task must not resurrect a superseded sample') await useChatStore.getState().selectSession('legacy-session') assert.deepEqual(useChatStore.getState().sessionUsage['legacy-session'], { inputTokens: 90, outputTokens: 10, totalTokens: 100 }) assert.equal('legacy-session' in useChatStore.getState().sessionContextWindow, false, 'historical samples without a denominator must stay unknown instead of borrowing the selected model window') } async function testUsageEventsKeepLatestSampleAndProjectIntoAssistant(): Promise { resetStore() const result = sendResult('run-context-usage') installFakeWindow(async () => result) await useChatStore.getState().send('question') useChatStore.getState().applyEvent({ runId: result.runId, type: 'usage', usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 }, contextWindow: 64_000 }) useChatStore.getState().applyEvent({ runId: result.runId, type: 'usage', usage: { inputTokens: 300, outputTokens: 40, totalTokens: 340 }, contextWindow: 128_000 }) assert.deepEqual(useChatStore.getState().sessionUsage[session.id], { inputTokens: 300, outputTokens: 40, totalTokens: 340 }, 'multiple usage events must use the latest sample, never a sum') assert.equal(useChatStore.getState().sessionContextWindow[session.id], 128_000) assert.deepEqual(assistantMessage().contextUsage, { requestId:result.runId, usage: { inputTokens: 300, outputTokens: 40, totalTokens: 340 }, contextWindow: 128_000 }, 'the live assistant sample must match the recovered session sample') useChatStore.getState().applyEvent({ runId: result.runId, type: 'usage', usage: { inputTokens: 50, outputTokens: 5, totalTokens: 55 } }) assert.equal(useChatStore.getState().sessionContextWindow[session.id], undefined, 'a newer usage event without a real denominator must clear the previous denominator') assert.deepEqual(assistantMessage().contextUsage, { requestId:result.runId, usage: { inputTokens: 50, outputTokens: 5, totalTokens: 55 } }) } async function testReopenedActiveRunUsesSnapshotSequence(): Promise { resetStore() const active = deferred() const contextUsage: ContextUsageSnapshot = { usage: { inputTokens: 128, outputTokens: 8, totalTokens: 136 }, contextWindow: 64_000 } installFakeWindow(async () => sendResult('unused')) window.ycode.chat.activeRun = async (sessionId) => { assert.equal(sessionId, 'reopened-session') return active.promise } window.ycode.session.messages = async () => { throw new Error('active run hydration must not fall back to disk') } const selection = useChatStore.getState().selectSession('reopened-session') useChatStore.getState().applyEvent({ sessionId: 'reopened-session', runId: 'run-reopen', sequence: 6, type: 'text', delta: ' queued' }) active.resolve(activeRunSnapshot('run-reopen', 'snapshot', 5, 'reopened-session', contextUsage)) await selection const state = useChatStore.getState() assert.equal(assistantMessage().content, 'snapshot queued', 'events after the active snapshot watermark must be replayed exactly once') assert.equal(assistantMessage().status, 'streaming') assert.equal(state.running?.runId, 'run-reopen') assert.equal(state.runSequences['run-reopen'], 6) assert.deepEqual(state.sessionUsage['reopened-session'], contextUsage.usage, 'an active snapshot must restore its persisted latest usage sample') assert.equal(state.sessionContextWindow['reopened-session'], 64_000, 'an active snapshot must restore the turn’s frozen context window') useChatStore.getState().applyEvent({ sessionId: 'reopened-session', runId: 'run-reopen', sequence: 6, type: 'text', delta: ' duplicate' }) assert.equal(assistantMessage().content, 'snapshot queued', 'a duplicate sequence must be ignored') } async function testStartedSnapshotAdoptionAcrossWindows(): Promise { resetStore() const localMessage: ChatMessage = { id: 'local-other', role: 'user', content: 'other task', createdAt: 1, status: 'done' } useChatStore.setState({ currentSessionId: 'other-session', messages: [localMessage], sessionMessages: { 'other-session': [localMessage] }, runs: {}, runSequences: {}, sendingSessions: [] }) const background = activeRunSnapshot('run-background', 'background', 0) useChatStore.getState().observeRun(background) assert.deepEqual(useChatStore.getState().messages, [localMessage], 'a started snapshot for another task must not steal focus') assert.equal(useChatStore.getState().sessionMessages[session.id]?.[1].content, 'background') assert.equal(useChatStore.getState().runs['run-background']?.sessionId, session.id) assert.equal(useChatStore.getState().running, null, 'a background started snapshot must update runs without changing the current running task') useChatStore.setState({ currentSessionId: session.id, messages: [], sessionMessages: {}, runs: {}, runSequences: {}, sendingSessions: [session.id] }) const local = activeRunSnapshot('run-local-send', 'local result', 0) useChatStore.getState().observeRun(local) assert.deepEqual(useChatStore.getState().messages, [], 'the window owning a pending local send must ignore its started broadcast') assert.deepEqual(useChatStore.getState().runs, {}) useChatStore.setState({ sendingSessions: [] }) useChatStore.getState().observeRun(local) const adopted = useChatStore.getState() assert.equal(adopted.messages[1].content, 'local result') assert.equal(adopted.running?.runId, 'run-local-send') assert.equal(adopted.sending, false) } async function testHydrationBuffersKnownRunAndFiltersByWatermark(): Promise { resetStore() const initial = activeRunSnapshot('run-watermark', 'old', 2) useChatStore.setState({ currentSessionId: session.id, messages: initial.messages, sessionMessages: { [session.id]: initial.messages }, runs: { 'run-watermark': { runId: 'run-watermark', assistantId: 'run-watermark-assistant', sessionId: session.id } }, runSequences: { 'run-watermark': 2 } }) const active = deferred() installFakeWindow(async () => sendResult('unused')) window.ycode.chat.activeRun = async () => active.promise const selection = useChatStore.getState().selectSession(session.id) useChatStore.getState().applyEvent({ sessionId: session.id, runId: 'run-watermark', sequence: 2, type: 'text', delta: ' duplicate' }) useChatStore.getState().applyEvent({ sessionId: session.id, runId: 'run-watermark', sequence: 8, type: 'text', delta: ' after' }) active.resolve(activeRunSnapshot('run-watermark', 'fresh', 7)) await selection assert.equal(assistantMessage().content, 'fresh after', 'hydration must buffer events for a known run and replay only sequence greater than the snapshot') assert.equal(useChatStore.getState().runSequences['run-watermark'], 8) useChatStore.getState().applyEvent({ sessionId: session.id, runId: 'run-watermark', sequence: 8, type: 'text', delta: ' duplicate' }) assert.equal(assistantMessage().content, 'fresh after') } async function testLateTerminalPreventsHydratedRunResurrection(): Promise { resetStore() const active = deferred() installFakeWindow(async () => sendResult('unused')) window.ycode.chat.activeRun = async () => active.promise const selection = useChatStore.getState().selectSession('terminal-session') useChatStore.getState().applyEvent({ sessionId: 'terminal-session', runId: 'run-late-terminal', sequence: 4, type: 'done', content: 'finished', tools: [] }) active.resolve(activeRunSnapshot('run-late-terminal', 'snapshot', 3, 'terminal-session')) await selection const state = useChatStore.getState() assert.equal(assistantMessage().content, 'finished') assert.equal(assistantMessage().status, 'done') assert.equal(state.runs['run-late-terminal'], undefined, 'a late active snapshot must not revive a completed run') assert.equal(state.running, null) assert.equal(state.runSequences['run-late-terminal'], 4) } async function testTerminalActiveSnapshotDoesNotCreateRunning(): Promise { for (const status of ['done', 'cancelled', 'error'] as const) { resetStore() installFakeWindow(async () => sendResult('unused')) const snapshot = activeRunSnapshot(`run-terminal-snapshot-${status}`, 'finished', 9) const assistant = snapshot.messages?.find((message) => message.id === snapshot.assistantMessageId) assert.ok(assistant) assistant.status = status if (status === 'error') assistant.error = 'snapshot failed' window.ycode.chat.activeRun = async () => snapshot await useChatStore.getState().selectSession(session.id) const state = useChatStore.getState() assert.equal(state.messages.find((message) => message.id === snapshot.assistantMessageId)?.status, status) assert.equal(state.running, null, `${status}: terminal activeRun snapshot must not create a running task`) assert.equal(state.runs[snapshot.runId], undefined, `${status}: terminal snapshot must not enter runs`) assert.equal(state.runSequences[snapshot.runId], snapshot.sequence) assert.equal(state.loading, false) if (status === 'error') assert.equal(state.error, 'snapshot failed') } } async function testLateStartedCannotReviveCompletedRun(): Promise { resetStore() installFakeWindow(async () => sendResult('unused')) const started = activeRunSnapshot('run-completed-watermark', 'started', 0) useChatStore.getState().observeRun(started) useChatStore.getState().applyEvent({ sessionId: session.id, runId: started.runId, sequence: 5, type: 'done', content: 'completed', tools: [] }) assert.equal(useChatStore.getState().runs[started.runId], undefined) assert.equal(useChatStore.getState().runSequences[started.runId], 5) useChatStore.getState().observeRun(activeRunSnapshot(started.runId, 'late started', 0)) const state = useChatStore.getState() assert.equal(state.runs[started.runId], undefined, 'a late started snapshot at or below the completed watermark must not recreate the run') assert.equal(state.running, null) assert.equal(assistantMessage().content, 'completed') assert.equal(assistantMessage().status, 'done') } 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 { resetStore() let inlinePayload: Parameters[0] | undefined installFakeWindow(async payload => { inlinePayload = payload; return sendResult('inline-layout') }) await useChatStore.getState().send(' 前后 ', { quotes: [{ id: 'q', kind: 'table', sourceId: 'source', markdown: '| x |', preview: '| x |' }], userContent: [{ type: 'text', text: ' 前' }, { type: 'quote', id: 'q' }, { type: 'text', text: '后 ' }] }) assert.equal(inlinePayload?.text, '前后') assert.deepEqual(inlinePayload?.userContent, [{ type: 'text', text: '前' }, { type: 'quote', id: 'q' }, { type: 'text', text: '后' }], 'trimmed edit and retry submissions must keep the inline reference order') resetStore() let referenceOnlySends = 0 installFakeWindow(async () => { referenceOnlySends++; return sendResult('reference-only') }) await useChatStore.getState().send('', { resources: ['C:/workspace/reference.md'] }) assert.equal(referenceOnlySends, 1, 'a resource-only draft must reach IPC instead of being silently discarded') resetStore() installFakeWindow(async () => { referenceOnlySends++; return sendResult('skill-only') }) await useChatStore.getState().send('', { skillIds: ['skill'] }) assert.equal(referenceOnlySends, 2, 'a skill-only draft must reach IPC') await testFailedCreatedSessionIsRemoved() await testFailedCreatedSessionWithMessagesIsRetained() await testUnsafeCleanupDoesNotMasqueradeAsHome() await testBufferedBackgroundEventPreventsDeletion() await testLateSendFailureCannotOverrideAnotherTask() await testModelChangePreservesFrozenUsage() await testRevertRestoresBranchUsage() await testStoredUsageRestoresPerSessionAndKeepsLegacyUnknown() await testUsageEventsKeepLatestSampleAndProjectIntoAssistant() await testReopenedActiveRunUsesSnapshotSequence() await testStartedSnapshotAdoptionAcrossWindows() await testHydrationBuffersKnownRunAndFiltersByWatermark() await testLateTerminalPreventsHydratedRunResurrection() await testTerminalActiveSnapshotDoesNotCreateRunning() await testLateStartedCannotReviveCompletedRun() 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()