import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { api } from '../src/api' import { store, mergeTaskSnapshot } 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,'listCardsSnapshot').mockImplementation(async id=>({cards:await api.listCards(id)})) 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() }) it('preserves rejected referenced input without retrying it or blocking later queued chat', async () => { const reference = { cardId: 'saved-result', resultVersion: 1, title: '整理结果' } const submit = vi.spyOn(api, 'sendMessage').mockRejectedValueOnce(new Error('400: reference no longer valid')) .mockResolvedValue({ message: { id: 'accepted', conversationId: 'a', role: 'user', parts: [], createdAt: '' } }) expect(await store.sendMessage('请解释重点', ['saved-upload'], 'retry-safe-id', reference)).toBe('rejected') const failed = store.getState().outbox[0] expect(failed).toMatchObject({ id: 'retry-safe-id', text: '请解释重点', attachmentIds: ['saved-upload'], taskRef: reference }) expect(failed.deliveryError).toBeTruthy() await store.flushOutbox() expect(submit).toHaveBeenCalledTimes(1) store.enqueueOutbox('先讨论其他事情', [], []) await store.flushOutbox() expect(submit).toHaveBeenCalledTimes(2) expect(store.getState().outbox).toHaveLength(1) store.clearOutboxReference(failed.id) await store.sendOutboxNow(failed.id) expect(submit).toHaveBeenLastCalledWith('a', '请解释重点', ['saved-upload'], 'retry-safe-id') expect(store.getState().outbox).toEqual([]) }) describe('work projection ordering and context', () => { it('keeps an earlier background task out of a newer foreground process', async () => { const current: Run = { id: 'new-topic', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } const child: Run = { ...current, id: 'old-child', kind: 'subagent', parentRunId: 'old-topic' } vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [current, child])) await store.openConversation('a') store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在回答新的问题' }, current.id)) store.applyEvent(event(2, 'agent.progress', { agentRun: child.id, note: '旧材料仍在整理' }, child.id)) expect(store.getState().live?.agentNotes[child.id]).toBeUndefined() store.applyEvent(event(3, 'agent.spawn', { agentRun: 'new-child', name: '新任务', task: '核对新问题' }, current.id)) store.applyEvent(event(4, 'agent.progress', { agentRun: 'new-child', note: '核对当前问题' }, 'new-child')) expect(store.getState().live?.agentNotes['new-child']).toBe('核对当前问题') store.applyEvent(event(5, 'agent.result', { agentRun: child.id, summary: '旧材料已整理' }, child.id)) expect(store.getState().live?.runId).toBe(current.id) expect(store.getState().live?.agentNotes[child.id]).toBeUndefined() }) 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('sends a versioned optional choice once and keeps it out of the conversation', async () => { const task = { ...card('work-choice', 'done'), work: { resultVersion: 2, readVersion: 0, result: '完整材料结果', decision: { mode: 'multiple' as const, state: 'open' as const } } } store.upsertCard(task) const response = deferred>>() const action = vi.spyOn(api, 'cardAction').mockReturnValue(response.promise) const payload = { resultVersion: 2, selectedIds: ['summary', 'facts'], freeText: '请保留来源' } const pending = store.cardAction(task, 'work.submit', payload) await store.cardAction(task, 'work.submit', payload) expect(action).toHaveBeenCalledOnce() expect(action).toHaveBeenCalledWith(task.id, 'work.submit', payload) response.resolve({ card: { ...task, work: { ...task.work, decision: { ...task.work.decision, state: 'submitted' } } }, receipt: { id: 'unexpected-work-receipt', conversationId: 'a', role: 'assistant', parts: [], createdAt: '' } }) await pending expect(store.getState().messages).toEqual([]) expect(store.getState().cardActionStates[task.id]).toBeUndefined() expect(store.getState().cards.find(c => c.id === task.id)?.work?.decision?.state).toBe('submitted') }) it('does not unlock an uncertain optional submission merely because its result is done', async () => { const task = { ...card('work-unknown', 'done'), work: { resultVersion: 1, readVersion: 0, result: '结果', decision: { mode: 'single' as const, state: 'open' as const } } } store.upsertCard(task) vi.spyOn(api, 'cardAction').mockRejectedValue(new TypeError('connection lost')) vi.mocked(api.listCards).mockResolvedValue([task]) await store.cardAction(task, 'work.submit', { resultVersion: 1, selectedIds: ['summary'] }) expect(store.getState().cardActionStates[task.id]).toBe('unknown') store.upsertCard({ ...task, work: { ...task.work, decision: { ...task.work.decision, state: 'submitted' } } }) expect(store.getState().cardActionStates[task.id]).toBeUndefined() }) it('settles successful metadata actions while an ordinary card still needs action', async () => { const task = { ...card('work-read', 'action_required'), work: { resultVersion: 1, readVersion: 0 } } store.upsertCard(task) vi.spyOn(api, 'cardAction').mockResolvedValue({ card: { ...task, work: { ...task.work, readVersion: 1 } } }) await store.cardAction(task, 'work.read', { resultVersion: 1 }) expect(store.getState().cardActionStates[task.id]).toBeUndefined() }) it('retains an explicit result reference across a queued send without adding it to chat text', async () => { const task = { ...card('work-ref', 'done'), work: { result: '完整结果', resultVersion: 3, readVersion: 3 } } store.upsertCard(task) store.referenceWork(task) const reference = store.getState().workReference! store.enqueueOutbox('比较重点', [], [], reference) store.clearWorkReference() expect(store.getState().outbox[0].taskRef).toEqual({ cardId: 'work-ref', resultVersion: 3, title: '整理材料' }) const send = vi.spyOn(api, 'sendMessage').mockResolvedValue({ message: { id: 'queued-ref', conversationId: 'a', role: 'user', parts: [], createdAt: '' } }) await store.flushOutbox() expect(send).toHaveBeenCalledWith('a', '比较重点', [], expect.any(String), reference) expect(store.getState().workReference).toBeNull() }) it('clears record selection and result reference when navigating to another conversation', async () => { const task = { ...card('work-record', 'done'), sourceRunId: 'child', work: { result: '完整结果', resultVersion: 1, readVersion: 1 } } store.upsertCard(task) store.openWorkRecord(task) store.referenceWork(task) expect(store.getState().workRecord?.runId).toBe('child') await store.openConversation('b') expect(store.getState().workRecord).toBeNull() expect(store.getState().workReference).toBeNull() }) 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([]) }) }) it('keeps a newer task result when an older projection arrives in a later stream event',()=>{ const task=(revision:number,executionState:string)=>({...card(),task:{schemaVersion:1,taskId:'t',attemptId:'a1',cardId:'task',projectId:'p',conversationId:'a',ownerInstanceId:'i',executionEpoch:1,revision,sourceSeq:revision,streamScope:'conversation:a',title:'整理',executionState,outcomeCompleteness:'partial',deliveryState:'available',phase:{id:'reading',label:'读取材料'}}}) as ActivityCard store.applyEvent(event(1,'card.created',{card:task(9,'completed')})) store.applyEvent(event(2,'card.updated',{card:task(8,'running')})) expect(store.getState().cards[0].task?.revision).toBe(9) expect(store.getState().cards[0].task?.executionState).toBe('completed') }) it('does not lose an event behind a detected sequence gap',async()=>{ vi.mocked(api.listCardsSnapshot).mockResolvedValue({cards:[],replaySeq:0}) await store.refreshCards('a') store.applyEvent(event(2,'card.updated',{card:card('later')})) expect(store.getState().lastSeq).toBe(0) store.applyEvent(event(1,'card.created',{card:card('earlier')})) await Promise.resolve() expect(store.getState().cards.map(c=>c.id)).toEqual(['later','earlier']) expect(store.getState().lastSeq).toBe(2) }) it('rejects old revisions and foreign attempts in the task merge contract',()=>{ expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'a',revision:8}).revision).toBe(9) expect(mergeTaskSnapshot({taskId:'t',attemptId:'a',revision:9},{taskId:'t',attemptId:'b',revision:10}).attemptId).toBe('a') }) it('keeps the newer explicit preview when a source response arrives late',async()=>{ const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'} const pending=deferred>>() vi.spyOn(api,'taskSource').mockReturnValue(pending.promise) const {docBridge}=await import('../src/components/Workbench') const opening=docBridge.onOpenTaskSource!('card',source) store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0}) pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'}) await opening expect(store.getState().previewActive.a).toBe('b') }) it('rejects a source response after leaving and returning to the same conversation',async()=>{ const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'} const pending=deferred>>() vi.spyOn(api,'taskSource').mockReturnValue(pending.promise) const opening=store.openTaskSource('card',source) await store.openConversation('b');await store.openConversation('a') store.requestPreview({id:'new',name:'New',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0}) pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'}) await opening expect(store.getState().previewActive.a).toBe('new') }) it('keeps the activated tab when a source request is still pending',async()=>{ store.requestPreview({id:'b',name:'B',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0}) store.requestPreview({id:'c',name:'C',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0}) const source={id:'s',artifactId:'a',contentHash:'hash',parserRevision:'v1',blockIndex:0,label:'A'} const pending=deferred>>() vi.spyOn(api,'taskSource').mockReturnValue(pending.promise) const opening=store.openTaskSource('card',source);store.activatePreview('b') pending.resolve({artifact:{id:'a',name:'A',kind:'document',projectId:'p',createdAt:'',updatedAt:'',size:0},source,versionState:'current'}) await opening expect(store.getState().previewActive.a).toBe('b') })