Why: Repeated progress and coupled scrolling obscured chat, and task choices lacked a reliable independent interaction path. Evidence: 285 frontend unit tests and 23 browser scenarios pass locally before the shared event-loader extraction; final combination checks follow in Dev. Impact: Coalesce cumulative progress, retain execution records, show timestamps and quiet notifications, support versioned work actions and explicit result references without automatic chat backfill. Refs: feature/workbench-flow-completion
486 lines
28 KiB
TypeScript
486 lines
28 KiB
TypeScript
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.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<ReturnType<typeof detail>>()
|
|
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
|
const send = deferred<Awaited<ReturnType<typeof api.sendMessage>>>()
|
|
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<ReturnType<typeof detail>>()
|
|
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<Awaited<ReturnType<typeof api.conversationDetail>>>()
|
|
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<ReturnType<typeof detail>>()
|
|
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<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('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<Awaited<ReturnType<typeof api.cardAction>>>()
|
|
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<never>()
|
|
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<never>()
|
|
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<never>()
|
|
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<Awaited<ReturnType<typeof api.resolveHITL>>>()
|
|
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<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([])
|
|
})
|
|
})
|