Files
dsh-context/tests/client/components/commsPanel.spec.ts
T
bowenliang123 2ce5d9a7db feat(client): narrow the comms timeline by the picked agent
- A pick anywhere (graph node, member card) now steers the comms timeline
  beside the task board: rows narrow to the traffic the picked agent sent or
  received, the dropdown steps aside for the pick chip (label, never a raw
  id), and an agent without traffic says so
- The pick governs while it stands: the dropdown's own choice yields on
  landing and never resurrects stale once the pick clears; without a pick the
  dropdown keeps its free per-participant browsing
- The pick key resolves once in the view (roster name -> session id, a
  subagent's own id) — the one key both boards narrow by; zh/en gains the
  no-comms note
2026-10-12 02:21:17 +08:00

217 lines
9.1 KiB
TypeScript

// CommsPanel — the on-demand cross-session message scan: the trigger, progress, staleness,
// per-agent filtering, and the merged timeline.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test, vi, expect } from 'vitest'
import { CommsPanel, type CommsPanelProps } from '../../../src/client/components/commsPanel'
import type { FleetDetailState } from '../../../src/client/fleetModel'
import type { AgentNode } from '../../../src/client/agentTree'
import type { CommsRow } from '../../../src/client/fleetDetail'
import type { FleetDetail } from '../../../src/shared/types'
import { DICT_EN } from '../../../src/client/i18n'
import { click, makeKit, mount, query, queryAll, text } from '../helpers/kit'
import { act } from 'react'
const kit = makeKit()
function nodeOf(id: string, over: Partial<AgentNode> = {}): AgentNode {
return {
id, label: id, depth: 0, family: 0, isCurrent: false, running: false, completed: false,
subagent: false, updatedAt: 0, head: null, requests: 0, billed: null, costUsage: null,
durationMs: null, identity: null, ...over,
}
}
const NODES = [nodeOf('root', { updatedAt: 5 }), nodeOf('w1', { updatedAt: 3 })]
const COMMS: CommsRow[] = [
{ key: 'w1:9', to: 'w1', time: 1_700_000_000_000, kind: 'relay', from: 'root', text: 'how far along?' },
{ key: 'root:4', to: 'root', time: 0, kind: 'settled', from: 'w1', text: 'Background subagent w1 finished and will do no further work unless you send it more.' },
{ key: 'w1:3', to: 'w1', time: 90, kind: 'mailbox', fromName: 'lead', text: 'old school' },
// A relay carrying the mailbox-era sender name.
{ key: 'w1:2', to: 'w1', time: 80, kind: 'relay', from: 'root', fromName: 'lead', text: 'named relay' },
// Neither an id nor a name: the bare arrow introduces the recipient.
{ key: 'w1:1', to: 'w1', time: 60, kind: 'relay', text: 'anonymous' },
]
function detailsOf(over: Partial<FleetDetailState> = {}): FleetDetailState {
return {
landed: new Map(),
pending: new Set(),
scanned: new Set(),
request: vi.fn(),
scan: vi.fn(),
refresh: vi.fn(),
...over,
}
}
function propsOf(over: Partial<CommsPanelProps> = {}): CommsPanelProps {
return {
nodes: NODES,
details: detailsOf(),
comms: COMMS,
labelOf: id => ({ root: 'Main Agent', w1: 'worker-bee' })[id] ?? id,
onPin: () => {},
pickId: null,
onPickClear: () => {},
t: kit.t,
fmt: kit.fmt,
...over,
}
}
describe('CommsPanel — the scan lifecycle', () => {
test('the collapsed state offers the scan; the button scans every node', async () => {
const details = detailsOf()
const m = await mount(h(CommsPanel, propsOf({ details })))
const scanBtn = query(m.container, '.lc-comms-scan')
assert.ok(text(scanBtn).includes('2'))
await click(scanBtn)
expect(details.scan).toHaveBeenCalledWith(NODES)
await m.unmount()
})
test('progress shows while reads are in flight, then the row count', async () => {
const details = detailsOf({
landed: new Map([['root', { detail: null, updatedAt: 5 }]]),
pending: new Set(['w1']),
scanned: new Set(['root', 'w1']),
})
const m = await mount(h(CommsPanel, propsOf({ details, comms: [] })))
assert.ok(text(m.container).includes('1/2'))
await m.unmount()
const done = detailsOf({
landed: new Map([
['root', { detail: null, updatedAt: 5 }],
['w1', { detail: { messages: [], truncated: false }, updatedAt: 3 }],
]),
scanned: new Set(['root', 'w1']),
})
const m2 = await mount(h(CommsPanel, propsOf({ details: done })))
assert.ok(text(m2.container).includes(DICT_EN['fleet.comms.rows'].replace('{n}', '5')))
await m2.unmount()
})
test('a scanned-then-moved session flags the panel stale; rescan refreshes', async () => {
const details = detailsOf({
landed: new Map([['root', { detail: null, updatedAt: 4 }]]),
scanned: new Set(['root']),
})
const m = await mount(h(CommsPanel, propsOf({ details, nodes: [nodeOf('root', { updatedAt: 5 })] })))
assert.ok(text(m.container).includes(DICT_EN['fleet.comms.stale']))
const buttons = queryAll(m.container, '.lc-inspector-btn')
await click(buttons[0])
expect(details.refresh).toHaveBeenCalled()
await m.unmount()
})
test('a scanned family with no messages says so', async () => {
const details = detailsOf({
landed: new Map([['root', { detail: null, updatedAt: 5 }]]),
scanned: new Set(['root']),
})
const m = await mount(h(CommsPanel, propsOf({ details, comms: [] })))
assert.ok(text(m.container).includes(DICT_EN['fleet.comms.empty']))
await m.unmount()
})
})
describe('CommsPanel — the timeline', () => {
const landedAll = new Map<string, { detail: FleetDetail | null; updatedAt: number }>([
['root', { detail: { messages: [], truncated: false }, updatedAt: 5 }],
['w1', { detail: { messages: [], truncated: true }, updatedAt: 3 }],
])
const scannedAll = new Set(['root', 'w1'])
test('rows render time, kind, parties, and text; parties pin', async () => {
const pins: (string | null)[] = []
const m = await mount(h(CommsPanel, propsOf({ details: detailsOf({ landed: landedAll, scanned: scannedAll }), onPin: id => { pins.push(id) } })))
const rows = queryAll(m.container, '.lc-comms-row')
assert.equal(rows.length, 5)
assert.ok(text(rows[0]).includes('relay'))
assert.ok(text(rows[0]).includes('Main Agent'))
assert.ok(text(rows[0]).includes('worker-bee'))
assert.ok(text(rows[0]).includes('how far along?'))
// A zero instant reads as a dash; the settled boilerplate renders localized.
assert.ok(text(rows[1]).includes('—'))
assert.ok(text(rows[1]).includes('settled'))
assert.ok(text(rows[1]).includes(DICT_EN['fleet.settled.finished']))
assert.ok(!text(rows[1]).includes('Background subagent'))
// An unrecognized settled phrasing falls back to the raw text.
const odd = await mount(h(CommsPanel, propsOf({
details: detailsOf({ landed: landedAll, scanned: scannedAll }),
comms: [{ key: 'w1:1', to: 'w1', time: 1, kind: 'settled', from: 'root', text: 'a brand-new phrasing' }],
})))
assert.ok(text(odd.container).includes('a brand-new phrasing'))
await odd.unmount()
// The mailbox row names its sender.
assert.ok(text(rows[2]).includes('lead'))
// The truncation note rides any landed detail's flag.
assert.ok(text(m.container).includes(DICT_EN['fleet.comms.truncated']))
const peers = queryAll(rows[0], '.lc-comms-peer')
await click(peers[0])
await click(peers[1])
assert.deepEqual(pins, ['root', 'w1'])
await m.unmount()
})
test('the agent filter narrows the rows', async () => {
const m = await mount(h(CommsPanel, propsOf({ details: detailsOf({ landed: landedAll, scanned: scannedAll }) })))
const select = query(m.container, 'select') as HTMLSelectElement
// The filter lists every involved agent plus the all entry.
assert.ok(text(select).includes('Main Agent'))
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set?.call(select, 'root')
select.dispatchEvent(new Event('change', { bubbles: true }))
})
assert.equal(queryAll(m.container, '.lc-comms-row').length, 3)
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set?.call(select, '')
select.dispatchEvent(new Event('change', { bubbles: true }))
})
assert.equal(queryAll(m.container, '.lc-comms-row').length, 5)
await m.unmount()
})
test('no rows yet hides the filter and the truncation note', async () => {
const details = detailsOf({
landed: new Map([['root', { detail: { messages: [], truncated: false }, updatedAt: 5 }]]),
scanned: new Set(['root']),
})
const m = await mount(h(CommsPanel, propsOf({ details, comms: [] })))
assert.equal(queryAll(m.container, 'select').length, 0)
assert.ok(!text(m.container).includes(DICT_EN['fleet.comms.truncated']))
await m.unmount()
})
test('a picked agent narrows the timeline; the pick chip clears it', async () => {
const clears: boolean[] = []
const m = await mount(h(CommsPanel, propsOf({
details: detailsOf({ landed: landedAll, scanned: scannedAll }),
pickId: 'root',
onPickClear: () => { clears.push(true) },
})))
// The pick governs: the dropdown steps aside, the chip names the label.
assert.equal(queryAll(m.container, 'select').length, 0)
assert.equal(queryAll(m.container, '.lc-comms-row').length, 3)
assert.ok(text(query(m.container, '.lc-tasks-filter')).includes('Main Agent'))
await click(query(m.container, '.lc-tasks-filter'))
assert.deepEqual([...clears], [true])
await m.unmount()
})
test('a picked agent without traffic names it', async () => {
const m = await mount(h(CommsPanel, propsOf({
details: detailsOf({ landed: landedAll, scanned: scannedAll }),
pickId: 'w9',
})))
assert.equal(queryAll(m.container, '.lc-comms-row').length, 0)
assert.ok(text(m.container).includes(DICT_EN['fleet.comms.noMatch']))
await m.unmount()
})
})