import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { api } from '../src/api' import { store } from '../src/store' import type { ActivityCard, Conversation, Message, Run, StreamEvent, SurfaceSnapshot } from '../src/contracts' function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void const promise = new Promise((ok, fail) => { resolve = ok; reject = fail }) return { promise, resolve, reject } } function card(id = 'task', status: ActivityCard['status'] = 'active', conversationId = 'a'): ActivityCard { return { id, status, conversationId, projectId: 'p', kind: 'progress', title: '整理材料', createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z' } } function detail(id: string, runs: Run[] = [], lastSeq = 0, resumeSeq = lastSeq) { return { conversation: { id, projectId: 'p', title: id, createdAt: '', updatedAt: '' } as Conversation, messages: [] as Message[], runs, activeHitl: [], lastSeq, resumeSeq } } function event(seq: number, type: StreamEvent['type'], payload: unknown, runId?: string, conversationId = 'a'): StreamEvent { return { seq, type, payload, runId, conversationId, ts: '2026-10-12T00:00:00Z', v: 1 } } class FakeEventSource { onopen = null onerror = null onmessage = null addEventListener() {} close() {} } beforeEach(async () => { vi.stubGlobal('EventSource', FakeEventSource) vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id)) vi.spyOn(api, 'getPlan').mockResolvedValue(null) vi.spyOn(api, 'getTodos').mockResolvedValue(null) vi.spyOn(api, 'listCards').mockResolvedValue([]) vi.spyOn(api, 'listTasks').mockResolvedValue([]) vi.spyOn(api, 'listArtifacts').mockResolvedValue([]) vi.spyOn(api, 'surfaceList').mockResolvedValue([]) vi.spyOn(api, 'projectSurfaces').mockResolvedValue([]) vi.spyOn(api, 'listProjectCards').mockResolvedValue([]) await store.openProject('p') await store.openConversation('a') await store.refreshCards('a') }) afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() }) describe('work projection ordering and context', () => { it('settles foreground completion and queued input when background events overtake its detail response', async () => { vi.useFakeTimers() const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run])) await store.openConversation('a') store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front')) store.upsertCard(card('background')) store.enqueueOutbox('继续整理', ['material'], ['材料']) const query = deferred>() vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise) const send = deferred>>() const submission = vi.spyOn(api, 'sendMessage').mockReturnValue(send.promise) store.applyEvent(event(2, 'run.completed', {}, 'front')) await vi.advanceTimersByTimeAsync(250) store.applyEvent(event(3, 'agent.progress', { agentRun: 'background', note: '仍在核对' }, 'background')) store.applyEvent(event(4, 'card.status', { cardId: 'background', status: 'done' }, 'background')) vi.mocked(api.listCards).mockResolvedValue([card('background', 'done')]) query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 2), messages: [ { id: 'reply', conversationId: 'a', runId: 'front', role: 'assistant', parts: [{ type: 'text', text: '整理完成' }], createdAt: '' }, ] }) await vi.advanceTimersByTimeAsync(0) expect(store.getState().runs.find(r => r.id === 'front')?.status).toBe('completed') expect(store.getState().messages.map(m => m.id)).toContain('reply') expect(store.getState().live).toBeNull() expect(store.getState().lastSeq).toBe(4) expect(store.getState().cards.find(c => c.id === 'background')?.status).toBe('done') expect(store.getState().outbox).toEqual([]) expect(submission).toHaveBeenCalledWith('a', '继续整理', ['material'], expect.any(String)) send.resolve({ message: { id: 'queued', conversationId: 'a', role: 'user', parts: [], createdAt: '' } }) await vi.advanceTimersByTimeAsync(0) }) it('waits for the newer terminal run snapshot before settling its live output', async () => { vi.useFakeTimers() const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run])) await store.openConversation('a') store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front')) const query = deferred>() vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise) const pending = store.reconcile() store.applyEvent(event(2, 'run.failed', { code: 'failed', message: '未完成' }, 'front')) query.resolve(detail('a', [{ ...run, status: 'completed' }], 1)) await pending expect(store.getState().runs[0].status).toBe('running') expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '正在整理' }) vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [{ ...run, status: 'failed' }], 2)) await vi.advanceTimersByTimeAsync(250) expect(store.getState().runs[0].status).toBe('failed') expect(store.getState().live).toBeNull() }) it('does not restore a resolved HITL from a detail snapshot overtaken by its resolution', async () => { const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run])) await store.openConversation('a') const query = deferred>>() vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise) const pending = store.reconcile() store.applyEvent(event(2, 'hitl.resolved', { cardId: 'ask' }, 'front')) query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 1), activeHitl: [ { id: 'ask', runId: 'front', conversationId: 'a', question: '继续?', kind: 'confirm', allowFreeInput: false, createdAt: '', resolved: false }, ] }) await pending expect(store.getState().activeHitl).toEqual([]) expect(store.getState().runs[0].status).toBe('completed') expect(store.getState().lastSeq).toBe(2) }) it('preserves a run and user message started after a detail query began', async () => { const query = deferred>() vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise) const pending = store.reconcile() vi.spyOn(api, 'sendMessage').mockResolvedValue({ message: { id: 'new-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' }, run: { id: 'new-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }, }) await store.sendMessage('继续', []) query.resolve(detail('a')) await pending expect(store.getState().runs.map(r => r.id)).toEqual(['new-run']) expect(store.getState().messages.map(m => m.id)).toEqual(['new-message']) expect(store.getState().live?.runId).toBe('new-run') }) it('drops a terminal reconciliation timer after leaving its conversation', async () => { vi.useFakeTimers() store.applyEvent(event(1, 'run.completed', {}, 'front')) await store.openConversation('b') const calls = vi.mocked(api.conversationDetail).mock.calls.length await vi.advanceTimersByTimeAsync(250) expect(api.conversationDetail).toHaveBeenCalledTimes(calls) expect(store.getState().currentConvId).toBe('b') }) it('does not regress a finished card on an older or duplicated event', () => { store.applyEvent(event(10, 'card.created', { card: card() })) store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'done' })) store.applyEvent(event(11, 'card.status', { cardId: 'task', status: 'active' })) store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'failed' })) expect(store.getState().cards[0].status).toBe('done') }) it('rejects late card events from a different conversation', async () => { await store.openConversation('b') store.applyEvent(event(10, 'card.created', { card: card('foreign') })) expect(store.getState().cards).toEqual([]) }) it('does not let a card query finish in another conversation', async () => { const query = deferred() vi.mocked(api.listCards).mockImplementation(id => id === 'a' ? query.promise : Promise.resolve([])) const pending = store.refreshCards('a') await store.openConversation('b') query.resolve([card('old')]) await pending expect(store.getState().cards).toEqual([]) }) it('keeps card events newer than an in-flight card snapshot', async () => { store.applyEvent(event(10, 'card.created', { card: card() })) const query = deferred() vi.mocked(api.listCards).mockReturnValueOnce(query.promise) const pending = store.refreshCards('a') store.applyEvent(event(11, 'card.status', { cardId: 'task', status: 'done' })) store.applyEvent(event(12, 'card.created', { card: card('new-task') })) query.resolve([card()]) await pending expect(store.getState().cards.find(c => c.id === 'task')?.status).toBe('done') expect(store.getState().cards.some(c => c.id === 'new-task')).toBe(true) }) it('preserves a status event that arrives before its first card snapshot', async () => { const query = deferred() vi.mocked(api.listCards).mockReturnValueOnce(query.promise) const pending = store.refreshCards('a') store.applyEvent(event(1, 'card.status', { cardId: 'task', status: 'done' })) query.resolve([card()]) await pending expect(store.getState().cards[0].status).toBe('done') }) it('does not let a delayed conversation snapshot replace the newly selected one', async () => { const query = deferred>() vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise) const pending = store.reconcile() await store.openConversation('b') query.resolve({ ...detail('a'), messages: [{ id: 'old-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' }] }) await pending expect(store.getState().currentConvId).toBe('b') expect(store.getState().messages).toEqual([]) }) it('restores foreground output through replay without replaying old card states', async () => { const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run], 10, 4)) vi.mocked(api.listCards).mockResolvedValue([card('task', 'done')]) await store.openConversation('a') await store.refreshCards('a') store.applyEvent(event(5, 'message.delta', { index: 0, text: '已经整理' }, 'front')) store.applyEvent(event(6, 'card.status', { cardId: 'task', status: 'active' })) expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '已经整理' }) expect(store.getState().cards[0].status).toBe('done') }) it('does not duplicate replayed foreground text', async () => { const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run], 0)) await store.openConversation('a') store.applyEvent(event(1, 'message.delta', { index: 0, text: '结论' }, 'front')) store.applyEvent(event(1, 'message.delta', { index: 0, text: '结论' }, 'front')) expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '结论' }) }) it('background notes do not create a foreground turn or block the composer', () => { store.applyEvent(event(1, 'agent.progress', { agentRun: 'background', note: '正在读取' }, 'background')) expect(store.getState().live).toBeNull() }) it('new task pages do not replace an open document or reopen a hidden right pane', () => { store.requestPreview({ id: 'doc', projectId: 'p', name: '需求.md', kind: 'markdown', size: 0, createdAt: '', updatedAt: '' }) store.setUI({ rightCollapsed: true }) const snap: SurfaceSnapshot = { identity: { surfaceId: 'page', pluginId: 'generative-ui', owner: 'o' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新成果', nodes: [] } } store.applyEvent(event(1, 'surface.updated', snap, 'background')) expect(store.getState().previewActive.a).toBe('doc') expect(store.getState().ui.rightCollapsed).toBe(true) expect(store.getState().surfaces[0].identity.surfaceId).toBe('page') }) it('resets project event cursor when changing projects and drops delayed project lists', async () => { await store.openProject('p') store.applyProjectEvent(event(100, 'card.created', { card: { ...card('p-card'), conversationId: undefined } })) const query = deferred() vi.mocked(api.listProjectCards).mockReturnValueOnce(query.promise) const pending = store.loadProjectCards() await store.openProject('q') store.applyProjectEvent(event(1, 'card.created', { card: { ...card('q-card'), projectId: 'q', conversationId: undefined } })) query.resolve([{ ...card('old-p'), conversationId: undefined }]) await pending expect(store.getState().projectCards.map(c => c.id)).toEqual(['q-card']) }) }) describe('shared action feedback', () => { it.each(['card', 'hitl'] as const)('clears its definite rejected %s action after A to B navigation', async kind => { const confirm = { ...card(`reject-${kind}`, 'action_required'), kind: 'confirm' as const, ...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:reject-${kind}`]] as [string, string][] }] } : {}) } store.upsertCard(confirm) const response = deferred() const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise) const pending = store.cardAction(confirm, 'confirm') await store.openConversation('b') response.reject(new Error('400: rejected')) await pending expect(store.getState().cardActionStates[confirm.id]).toBeUndefined() expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined() expect(store.getState().cards).toEqual([]) expect(store.getState().messages).toEqual([]) vi.mocked(api.listCards).mockResolvedValue([confirm]) await store.openConversation('a') await store.refreshCards('a') await store.cardAction(confirm, 'confirm') expect(request).toHaveBeenCalledTimes(2) }) it.each([ ['card', new TypeError('connection lost')], ['card', new Error('503: unavailable')], ['hitl', new TypeError('connection lost')], ['hitl', new Error('503: unavailable')], ] as const)('retains unknown feedback for an off-context %s request with %s', async (kind, failure) => { const id = `unknown-${kind}-${failure instanceof TypeError ? 'network' : 'server'}` const confirm = { ...card(id, 'action_required'), kind: 'confirm' as const, ...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:${id}`]] as [string, string][] }] } : {}) } store.upsertCard(confirm) const response = deferred() const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise) const pending = store.cardAction(confirm, 'confirm') await store.openConversation('b') response.reject(failure) await pending expect(store.getState().cardActionStates[confirm.id]).toBe('unknown') if (kind === 'hitl') expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBe('unknown') vi.mocked(api.listCards).mockResolvedValue([confirm]) await store.openConversation('a') await store.refreshCards('a') await store.cardAction(confirm, 'confirm') expect(request).toHaveBeenCalledTimes(1) store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id })) expect(store.getState().cardActionStates[confirm.id]).toBeUndefined() expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined() }) it('does not let an old rejected action clear feedback owned by a newer HITL projection', async () => { const confirm = { ...card('replacement', 'action_required'), kind: 'confirm' as const } store.upsertCard(confirm) const old = deferred() vi.spyOn(api, 'cardAction').mockReturnValue(old.promise) const first = store.cardAction(confirm, 'confirm') store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id })) const projected = { ...confirm, body: [{ type: 'kv' as const, kv: [['来源', 'hitl:replacement']] as [string, string][] }] } store.applyEvent(event(2, 'card.updated', { card: projected })) const replacement = deferred>>() vi.spyOn(api, 'resolveHITL').mockReturnValue(replacement.promise) const second = store.cardAction(projected, 'confirm') old.reject(new Error('400: rejected')) await first expect(store.getState().cardActionStates.replacement).toBe('submitting') expect(store.getState().cardActionStates['hitl:replacement']).toBe('submitting') vi.mocked(api.listCards).mockResolvedValue([{ ...projected, status: 'resolved' }]) replacement.resolve({ resolved: true, pivoted: false, pivotText: '' }) await second expect(store.getState().cardActionStates.replacement).toBeUndefined() }) it('does not append another conversation receipt to the currently open conversation', async () => { const foreign = { ...card('other-confirm', 'action_required', 'b'), kind: 'confirm' as const } store.applyProjectEvent(event(1, 'card.created', { card: foreign })) vi.spyOn(api, 'cardAction').mockResolvedValue({ card: { ...foreign, status: 'resolved' }, receipt: { id: 'foreign-receipt', conversationId: 'b', role: 'system', createdAt: '', parts: [{ type: 'text', text: 'other conversation outcome' }], } }) await store.cardAction(foreign, 'confirm') expect(store.getState().messages).toEqual([]) expect(store.getState().projectCards[0].status).toBe('resolved') }) it('unlocks a completed read-only recheck even while confirmation remains pending', async () => { const confirm = { ...card('recheck', 'action_required'), kind: 'confirm' as const } store.upsertCard(confirm) vi.spyOn(api, 'cardAction').mockResolvedValue({ card: confirm }) await store.cardAction(confirm, 'recheck') expect(store.getState().cardActionStates.recheck).toBeUndefined() }) it('does not overwrite a newer resolved event with the older action response', async () => { const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const } store.upsertCard(confirm) const response = deferred<{ card: ActivityCard }>() vi.spyOn(api, 'cardAction').mockReturnValue(response.promise) vi.mocked(api.listCards).mockResolvedValue([confirm]) const pending = store.cardAction(confirm, 'confirm') store.applyEvent(event(1, 'card.resolved', { cardId: 'confirm' })) response.resolve({ card: confirm }) await pending expect(store.getState().cards[0].status).toBe('resolved') }) it('shares one lock across a raw HITL and its activity-card projection', async () => { const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, body: [{ type: 'kv', kv: [['来源', 'hitl:ask']] }] } satisfies ActivityCard store.upsertCard(confirm) const response = deferred<{ resolved: boolean; pivoted: boolean; pivotText: string }>() const request = vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) const first = store.resolveHITL('ask', { choiceId: 'yes' }) const second = store.cardAction(confirm, 'yes') expect(store.getState().cardActionStates.confirm).toBe('submitting') expect(request).toHaveBeenCalledTimes(1) vi.mocked(api.listCards).mockResolvedValue([{ ...confirm, status: 'resolved' }]) response.resolve({ resolved: true, pivoted: false, pivotText: '' }) await Promise.all([first, second]) expect(store.getState().cardActionStates.confirm).toBeUndefined() }) it('uses the server cancellation outcome rather than labelling every resolved HITL executed', async () => { const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, body: [{ type: 'kv', kv: [['来源', 'hitl:ask']] }] } satisfies ActivityCard store.upsertCard(confirm) vi.spyOn(api, 'resolveHITL').mockResolvedValue({ resolved: true, pivoted: false, pivotText: '' }) vi.mocked(api.listCards).mockResolvedValue([{ ...confirm, status: 'dismissed' }]) await store.resolveHITL('ask', { choiceId: 'no' }) expect(store.getState().cards[0].status).toBe('dismissed') }) it('leaves a new conversation untouched when an older send returns', async () => { const response = deferred>>() vi.spyOn(api, 'sendMessage').mockReturnValue(response.promise) const pending = store.sendMessage('整理材料', []) await store.openConversation('b') response.resolve({ message: { id: 'old-message', conversationId: 'a', role: 'user', createdAt: '', parts: [{ type: 'text', text: '整理材料' }] }, run: { id: 'old-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } }) await pending expect(store.getState().messages).toEqual([]) expect(store.getState().live).toBeNull() }) it('locks a card before awaiting so two views submit one effect', async () => { const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, actions: [{ id: 'confirm', label: '生成草稿' }] } const response = deferred<{ card: ActivityCard }>() const calls = vi.spyOn(api, 'cardAction').mockReturnValue(response.promise) store.upsertCard(confirm) const first = store.cardAction(confirm, 'confirm') const second = store.cardAction(confirm, 'confirm') expect(store.getState().cardActionStates?.confirm).toBe('submitting') expect(calls).toHaveBeenCalledTimes(1) response.resolve({ card: { ...confirm, status: 'resolved' } }) await Promise.all([first, second]) expect(store.getState().cards[0].status).toBe('resolved') expect(store.getState().cardActionStates?.confirm).toBeUndefined() }) it('keeps an uncertain effect disabled until a server outcome is observed', async () => { const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const } store.upsertCard(confirm) vi.spyOn(api, 'cardAction').mockRejectedValue(new TypeError('connection lost')) vi.mocked(api.listCards).mockResolvedValue([confirm]) await store.cardAction(confirm, 'confirm') expect(store.getState().cardActionStates?.confirm).toBe('unknown') store.applyEvent(event(1, 'card.resolved', { cardId: 'confirm' })) expect(store.getState().cardActionStates?.confirm).toBeUndefined() }) it('does not write a completed old action into a different conversation', async () => { const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const } const response = deferred<{ card: ActivityCard }>() vi.spyOn(api, 'cardAction').mockReturnValue(response.promise) const pending = store.cardAction(confirm, 'confirm') await store.openConversation('b') response.resolve({ card: { ...confirm, status: 'resolved' } }) await pending expect(store.getState().cards).toEqual([]) }) })