Deliver a conversation-first progressive workbench with safe live state
Why: make material, background work, confirmations and files accessible to nontechnical users across desktop and mobile. Evidence: Dev SELF receipts for Frontend tsc, 190 tests, knip baseline, build, nine fixture browser cases and two real HTTP/browser smoke cases using MockRunner. Impact: existing API and permissions remain authoritative; stale events/queries and duplicate effects are guarded, while one file preview and reading state are preserved. Refs: work-companion-ui design r0.2 and plan r1 U2-U5; UI01-UI13.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((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.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('work projection ordering and context', () => {
|
||||
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<ActivityCard[]>()
|
||||
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<ActivityCard[]>()
|
||||
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<ActivityCard[]>()
|
||||
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<ReturnType<typeof detail>>()
|
||||
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<ActivityCard[]>()
|
||||
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('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<Awaited<ReturnType<typeof api.sendMessage>>>()
|
||||
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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user