Why: Fast and slow channel cards duplicate across panes and completed replies hide process output. Evidence: npm test; npm run build; 12 isolated Playwright scenarios with reload and responsive widths. Impact: Preserve ordered folded process history, load durable run events, and give work cards one detailed pane. Refs: Agent-System-Dev feature/work-companion-visibility UI1 UI2 UI3
309 lines
16 KiB
TypeScript
309 lines
16 KiB
TypeScript
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('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('会覆盖旧草稿,原件保留')
|
||
})
|
||
})
|