Files
Agent-System-Frontend/test/conversation-work.test.ts
weichen 06fa1a40a6 Keep conversations ordered while background work updates independently
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
2026-10-15 07:05:57 +00:00

332 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { setPersistedOpen } from '@ui'
import type { ActivityCard, HITLCard, Message } from '../src/contracts'
import { Conversation } from '../src/components/Conversation'
import { ConversationWork } from '../src/components/ConversationWork'
import { ProcessTimeline } from '../src/components/ProcessTimeline'
import { store, type LiveState } from '../src/store'
// SSR has no browser subscription snapshot. Keep the production store/actions and
// real UI renderers; substitute only the hook boundary with a complete snapshot.
const snapshot = vi.hoisted(() => ({ state: undefined as ReturnType<typeof store.getState> | undefined }))
vi.mock('../src/store', async importOriginal => {
const actual = await importOriginal<typeof import('../src/store')>()
return { ...actual, useStore: (select: (s: ReturnType<typeof store.getState>) => unknown) => select(snapshot.state!) }
})
const timestamp = '2026-10-12T10:00:00Z'
function card(over: Partial<ActivityCard> = {}): ActivityCard {
return {
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1',
kind: 'progress', title: '整理项目材料', status: 'active', tone: 'info',
body: [{ type: 'kv', kv: [['状态', '进行中'], ['正在做', '核对会议结论'], ['材料键', 'private-material-key']] }],
createdAt: timestamp, updatedAt: timestamp, ...over,
}
}
function reply(parts: Message['parts']): Message {
return { id: 'msg_1', conversationId: 'cnv_1', role: 'assistant', runId: 'run_1', createdAt: timestamp, parts }
}
function hitl(over: Partial<HITLCard> = {}): HITLCard {
return { id: 'hitl_detail1', runId: 'run_1', conversationId: 'cnv_1', kind: 'confirm',
question: '执行工具 run_shell?', detail: '{"command":"printf confirm-record"}',
choices: [{ id: 'yes', label: '执行', description: '按已确认的参数调用', impact: '会访问外部资源并返回结果' },
{ id: 'no', label: '取消', description: '跳过本次调用', impact: '不会执行本次操作' }],
allowFreeInput: false, createdAt: timestamp, resolved: false, ...over }
}
function render() { return renderToStaticMarkup(createElement(Conversation)) }
function renderWork() { return renderToStaticMarkup(createElement(ConversationWork)) }
function completedResults(): ActivityCard[] {
// The host lists cards newest-first; submit_card publishes markdown results.
return [
card({ id: 'result_latest', kind: 'result', title: '最新整理成果', status: 'done', tone: 'ok',
updatedAt: '2026-10-12T12:00:00Z', body: [{ type: 'markdown', markdown: '最新成果的主要结论' }] }),
card({ id: 'result_older', kind: 'result', title: '先前整理成果', status: 'done', tone: 'ok', pinned: true,
updatedAt: '2026-10-12T10:00:00Z', body: [{ type: 'markdown', markdown: '先前成果的主要结论' }] }),
]
}
beforeEach(() => {
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', false)
setPersistedOpen('conversation-work:prj_1:cnv_1', false)
const base = store.getState()
snapshot.state = {
...base, currentProjectId: 'prj_1', currentConvId: 'cnv_1', connected: true,
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '讨论项目', createdAt: timestamp, updatedAt: timestamp }],
messages: [], cards: [], runs: [], activeHitl: [], live: null, artifacts: [], outbox: [], posting: false,
ui: { ...base.ui, rightCollapsed: true, autoCollapseProcess: true },
cardActionStates: {},
}
})
describe('conversation work reading', () => {
it('keeps ordinary Q&A free of an empty progress object', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '可以先确定项目目标' }])]
const html = render()
expect(html).toContain('可以先确定项目目标')
expect(html).not.toContain('data-conversation-work')
expect(html).not.toContain('data-testid="input-status"')
})
it('renders stable semantic timestamps on user and assistant messages', () => {
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理材料' }]), id: 'user', role: 'user' }, reply([{ type: 'text', text: '材料已收到' }])]
const html = render()
expect(html.match(/<time class="message-timestamp"/g)).toHaveLength(2)
expect(html.match(/dateTime="2026-10-12T10:00:00.000Z"/g)).toHaveLength(2)
expect(html).toContain('title="2026/10/12')
})
it('announces unread work without inserting its full result into the chat', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ status: 'done', work: { result: 'background-result-full-text', resultVersion: 2, readVersion: 1 } })]
const html = render()
expect(html).toContain('1 项工作有新结果')
expect(html).not.toContain('background-result-full-text')
})
it('shows an explicit removable result reference without copying its content into the draft', () => {
snapshot.state!.workReference = { cardId: 'work', resultVersion: 2, title: '材料整理' }
const html = render()
expect(html).toContain('基于「材料整理」的第 2 版结果')
expect(html).toContain('aria-label="移除工作结果引用"')
})
it('shows the current real work with the auxiliary pane closed', () => {
snapshot.state!.cards = [card()]
const html = render()
expect(html).toContain('整理项目材料')
expect(html).toContain('核对会议结论')
expect(html).not.toContain('private-material-key')
})
it('shows the newest completed result by default with older results in closed history', () => {
snapshot.state!.cards = completedResults()
const html = renderWork()
expect(html).toContain('data-card-id="result_latest"')
expect(html).toContain('最新成果的主要结论')
expect(html).not.toContain('data-card-id="result_older"')
expect(html).not.toContain('先前成果的主要结论')
expect(html).toContain('更多工作与成果 · 1 项')
setPersistedOpen('conversation-work:prj_1:cnv_1', true)
expect(renderWork()).toContain('先前成果的主要结论')
})
it('keeps the newest result ahead of an already resolved confirmation', () => {
snapshot.state!.cards = [card({ id: 'confirm_resolved', kind: 'confirm', status: 'resolved', title: '已处理确认',
updatedAt: '2026-10-12T13:00:00Z' }), ...completedResults()]
const html = renderWork()
expect(html).toContain('最新成果的主要结论')
expect(html).not.toContain('data-card-id="confirm_resolved"')
expect(html).toContain('更多工作与成果 · 2 项')
})
it.each([false, true])('keeps active work ahead of completed results (pinned=%s)', pinned => {
const [latest, older] = completedResults()
snapshot.state!.cards = [latest, card({ id: 'work_active', title: '正在核验材料',
updatedAt: '2026-10-12T11:00:00Z',
body: [{ type: 'kv', kv: [['正在做', '核对活动工作的结论']] }] }), older]
if (pinned) snapshot.state!.cards.push(card({ id: 'work_pinned', pinned: true, title: '优先核验材料',
updatedAt: '2026-10-12T09:00:00Z',
body: [{ type: 'kv', kv: [['正在做', '核对固定工作的结论']] }] }))
const html = renderWork()
expect(html).toContain(pinned ? '核对固定工作的结论' : '核对活动工作的结论')
expect(html).not.toContain('data-card-id="result_latest"')
expect(html).not.toContain('data-card-id="result_older"')
if (pinned) expect(html).not.toContain('data-card-id="work_active"')
})
it('leaves completed results to the visible right work pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = completedResults()
const html = renderWork()
expect(html).toBe('')
})
it('keeps the work card in one detail location when the right work pane is visible', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card()]
const html = render()
expect(html).not.toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('核对会议结论')
})
it('keeps the conversation detail when the right pane is reading files', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.ui.rightTab = 'files'
snapshot.state!.cards = [card()]
expect(render()).toContain('核对会议结论')
})
it('keeps secondary message operations available through progressive disclosure', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '已经整理好要点' }])]
const html = render()
expect(html).toContain('已经整理好要点')
expect(html).toContain('消息操作')
expect(html).not.toContain('排除出上下文')
expect(html).not.toContain('重新生成')
})
it('keeps failure impact and coverage limits visible without expanding history', () => {
snapshot.state!.cards = [card({ status: 'failed', tone: 'danger', body: [
{ type: 'kv', kv: [['状态', '失败'], ['当前发现', '已整理两份材料'], ['剩余', '评审意见未能读取,完整结论尚未生成']] },
] })]
const html = render()
expect(html).toContain('已整理两份材料')
expect(html).toContain('评审意见未能读取,完整结论尚未生成')
})
it('keeps a completed result limitation visible while another task is active', () => {
snapshot.state!.cards = [card(), card({ id: 'card_partial', status: 'done', tone: 'warn', title: '已整理部分材料',
body: [{ type: 'kv', kv: [['当前发现', '两份材料已整理'], ['剩余', '第三份材料未能读取,请补充清晰版本']] }],
})]
const html = render()
expect(html).toContain('第三份材料未能读取,请补充清晰版本')
})
it('hides running command records while keeping the final reply readable', () => {
const live: LiveState = { runId: 'run_1', phase: 'responding', startedAt: Date.now(), agentNotes: {}, items: [
{ kind: 'tool', name: 'run_shell', callId: 'call_1', args: 'secret-command-argument', output: 'raw-command-output', state: 'running', startedAt: Date.now(), endedAt: 0 },
{ kind: 'final' }, { kind: 'text', index: 1, text: '已找到需要补充的两项要求' },
] }
snapshot.state!.live = live
const html = render()
expect(html).toContain('已找到需要补充的两项要求')
expect(html).not.toContain('secret-command-argument')
expect(html).not.toContain('raw-command-output')
expect(html).toContain('执行记录')
})
it('uses a real compact stage without exposing an internal tool name', () => {
snapshot.state!.live = { runId: 'run_1', phase: 'acting', startedAt: Date.now(), agentNotes: {}, items: [
{ kind: 'tool', name: 'run_shell', callId: 'call_1', args: '', output: '', state: 'running', startedAt: Date.now(), endedAt: 0 },
] }
const html = render()
expect(html).toContain('正在处理')
expect(html).not.toContain('run_shell')
})
it('exposes anchored required confirmation outside work history', () => {
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理资料' }]), role: 'user' }]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
const html = render()
expect(html).toContain('新增一份项目草稿')
expect(html).toContain('确认新增')
})
it('shows only a location link for anchored confirmation when the work pane owns its actions', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理资料' }]), role: 'user' }]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
const html = render()
expect(html).toContain('新增一份项目草稿')
expect(html).toContain('在工作栏处理')
expect(html).not.toContain('确认新增')
})
it('keeps a submitted card as a location link when its full body is in the work pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ id: 'result_1', kind: 'result', title: '材料核对结果', status: 'done',
body: [{ type: 'markdown', markdown: '材料核对的完整正文' }] })]
snapshot.state!.messages = [reply([{ type: 'card', card: { cardId: 'result_1', kind: 'result', data: [] } }])]
const html = render()
expect(html).toContain('材料核对结果')
expect(html).not.toContain('展开全文')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('展开全文')
})
it('keeps a required confirmation accessible when its anchor is an assistant message', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '准备新增项目草稿' }])]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
expect(render()).toContain('确认新增')
})
it('keeps a thinking-only record available for deliberate review', () => {
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'thinking', process: true, text: 'private-internal-deliberation' }],
live: true, idKey: 'thinking-test', renderPart: () => null,
}))
expect(html).toContain('private-internal-deliberation')
snapshot.state!.messages = [reply([{ type: 'thinking', process: true, text: 'private-internal-deliberation' },
{ type: 'text', text: '已完成核对' }])]
expect(render()).toContain('执行记录')
})
it('lets readers reopen the full output of a grouped read action', () => {
setPersistedOpen('tool-record:read-history:call-read:0', true)
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'tool_call', process: true,
toolCall: { id: 'call-read', name: 'fs_read_file', arguments: '{"path":"材料.txt"}' },
toolOutput: 'full-read-output' } as Message['parts'][number]],
live: false, idKey: 'read-history', renderPart: () => null,
}))
expect(html).toContain('full-read-output')
})
it('blocks repeated HITL choices when the shared action result is unknown', () => {
snapshot.state!.cardActionStates = { 'hitl:hitl_1': 'unknown' }
snapshot.state!.activeHitl = [{ id: 'hitl_1', runId: 'run_1', kind: 'choice', question: '选择整理范围',
conversationId: 'cnv_1', allowFreeInput: false, createdAt: timestamp, resolved: false,
choices: [{ id: 'all', label: '全部材料', description: '整理三份材料', impact: '生成一份草稿' }],
}]
const html = render()
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>[\s\S]*?全部材料/)
expect(html).toContain('正在核实处理结果')
})
it('folds command details while retaining the confirmation and consequences', () => {
snapshot.state!.activeHitl = [hitl()]
const html = render()
expect(html).not.toContain('confirm-record')
expect(html).toContain('执行工具 run_shell?')
expect(html).toContain('按已确认的参数调用')
expect(html).toContain('会访问外部资源并返回结果')
expect(html).toContain('操作详情')
})
it('makes the original command detail readable after the user expands it', () => {
snapshot.state!.activeHitl = [hitl()]
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', true)
const html = render()
expect(html).toMatch(/aria-expanded="true"[^>]*>[\s\S]*?操作详情/)
expect(html).toContain('confirm-record')
})
it('keeps a blocking restriction visible outside the operation detail fold', () => {
snapshot.state!.activeHitl = [hitl({ blocked: true, blockReason: '目标文件需要先完成评审,当前不能覆盖' })]
const html = render()
expect(html).toContain('目标文件需要先完成评审,当前不能覆盖')
expect(html).not.toContain('confirm-record')
})
it('folds the exact workflow script suffix supplied in the question', () => {
snapshot.state!.activeHitl = [hitl({ question: '运行这段脚本?\nprint("workflow-record-script")', detail: undefined })]
const html = render()
expect(html).toContain('运行这段脚本?')
expect(html).toContain('会访问外部资源并返回结果')
expect(html).not.toContain('workflow-record-script')
expect(html).toContain('操作详情')
})
it('retains the full workflow script and separate detail on deliberate expansion', () => {
snapshot.state!.activeHitl = [hitl({ question: '运行这段脚本?\nprint("workflow-record-script")' })]
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', true)
const html = render()
expect(html).toContain('workflow-record-script')
expect(html).toContain('confirm-record')
expect(html).toMatch(/aria-expanded="true"[^>]*>[\s\S]*?操作详情/)
})
it('retains multiline business consequences in an ordinary question', () => {
snapshot.state!.activeHitl = [hitl({ question: '批准计划并继续执行?\n会覆盖旧草稿,原件保留', detail: undefined })]
expect(render()).toContain('会覆盖旧草稿,原件保留')
})
})