Files
dsh-context/tests/client/components/agentGraph.spec.ts
T
Bowen Liang d3227ff3c2 fix(client): stop a chart edge swipe from navigating the browser history
A trackpad swipe past either edge of the trend chart's horizontal scroller
chained into the page and the browser read it as its history gesture, so a
few scrolls in the Context tab could leave the whole app for another page in
the tab's history (back at the left edge, forward at the right).

Two layers, matching what the harness already does for its own horizontal
scrollers: `overscroll-behavior-x: contain` on the chart scroller and the
agent graph's stage stops the chaining (Chromium/Firefox), and overscroll.ts
cancels the horizontal-dominant wheel the scroller cannot consume at either
boundary for WebKit, which still navigates on horizontal overscroll with the
property set (WebKit bug 240183). Vertical-dominant gestures are untouched,
so a wheel over the chart still scrolls the page.
2026-09-08 19:55:40 +08:00

401 lines
17 KiB
TypeScript

// AgentGraph (src/client/components/agentGraph.tsx) — the Agent network card
// rendered for real against a faithful in-memory sessions face: tree
// rendering with donut/ring geometry, hover inspector, click/keyboard
// navigation, catalog refresh, live snapshot updates, and every degrade arm
// (no service, no anchor, no stats).
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeAgentGraph, ringColorOf } from '../../../src/client/components/agentGraph'
import type { AgentSelfStats } from '../../../src/client/agentTree'
import { TestClientCtx, asClientCtx } from '../helpers/harness'
import { flush, hover, makeKit, mount, query, queryAll, text, unhover, wheel } from '../helpers/kit'
const kit = makeKit()
function timeline(total: number, requests = 0): unknown {
return {
ok: true,
contextWindow: 1000,
current: { system: 10, tools: 20, user: total, inject: 0, assistant: 0, tool: 0, total: total + 30 },
requests: Array.from({ length: requests }, (_, i) => ({
seq: i + 1, time: 0, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 1,
})),
events: [],
nodes: [],
droppedNodes: 0,
archive: [],
}
}
/** Click an SVG node (jsdom's SVGElement has no HTMLElement.click). */
async function clickEl(el: Element): Promise<void> {
await act(async () => {
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})
}
/** A faithful in-memory `ctx.sessions` double: snapshot feed + navigation + catalog refresh. */
class FakeSessions {
opened: string[] = []
refreshed: string[] = []
rejectRefresh = false
private listeners = new Set<() => void>()
state: unknown
constructor(byId: Record<string, unknown>) {
this.state = { byId }
}
readonly list = {
getSnapshot: (): unknown => this.state,
subscribe: (fn: () => void): () => void => {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
},
}
open(id: string): void {
this.opened.push(id)
}
refreshSubagents(parentSessionId: string): Promise<void> {
this.refreshed.push(parentSessionId)
return this.rejectRefresh ? Promise.reject(new Error('catalog unavailable')) : Promise.resolve()
}
/** Swap the snapshot and notify (act-wrapped by the caller via flush). */
setState(byId: Record<string, unknown>): void {
this.state = { byId }
for (const fn of this.listeners) fn()
}
}
function makeView(sessions: unknown, options: { locale?: 'en' | 'zh' } = {}) {
const ctx = new TestClientCtx({ locale: options.locale, services: { sessions } })
return makeAgentGraph(asClientCtx(ctx), options.locale === 'zh' ? makeKit('zh') : kit)
}
function selfStats(over: Partial<AgentSelfStats> = {}): AgentSelfStats {
return {
head: { tokens: 500, window: 1000, pct: 50, parts: [{ key: 'user', color: '#22c55e', value: 500 }] },
billed: 1200,
requests: 3,
...over,
}
}
function family(): Record<string, unknown> {
return {
root: {
displayTitle: 'Main Agent', running: true, updatedAt: 10,
projectionValues: { contextTimeline: timeline(200, 2) },
},
worker: {
parentId: 'root', origin: 'subagent', running: true, completed: false, updatedAt: 8,
projectionValues: {
contextTimeline: timeline(800, 5),
subagent: { mode: 'continuable', label: 'worker-bee' },
subagentTiming: { settledMs: 42000 },
tokenUsage: { uncachedInputTokens: 100, outputTokens: 40, cacheReadTokens: 10, cacheWriteTokens: 0 },
},
},
done: {
parentId: 'root', origin: 'subagent', running: false, completed: true, updatedAt: 6,
projectionValues: {
contextPressure: { projectedTokens: 950, contextWindow: 1000 },
subagent: { mode: 'one-shot' },
},
},
}
}
describe('AgentGraph — degrade arms', () => {
test('no sessions service renders nothing (and mounts/unmounts cleanly)', async () => {
const ctx = new TestClientCtx()
const View = makeAgentGraph(asClientCtx(ctx), kit)
const m = await mount(h(View, { sessionId: 's1', self: selfStats() }))
assert.equal(text(m.container), '')
await m.unmount()
})
test('a service without the list feed renders nothing', async () => {
const View = makeView({ open: () => {} })
const m = await mount(h(View, { sessionId: 's1' }))
assert.equal(text(m.container), '')
await m.unmount()
})
test('missing or empty session id anchors nothing', async () => {
const face = new FakeSessions(family())
const View = makeView(face)
const m1 = await mount(h(View, { self: selfStats() }))
assert.equal(text(m1.container), '')
await m1.unmount()
const m2 = await mount(h(View, { sessionId: '', self: selfStats() }))
assert.equal(text(m2.container), '')
await m2.unmount()
// Neither mount touched the catalog.
assert.deepEqual(face.refreshed, [])
})
})
describe('AgentGraph — the family tree', () => {
test('renders nodes, chips, links, inspector, and the legend', async () => {
const face = new FakeSessions(family())
const View = makeView(face)
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
const nodes = queryAll(m.container, 'g.lc-agent-node')
assert.equal(nodes.length, 3)
assert.deepEqual(face.refreshed, ['root'])
// Chips: 3 agents, 2 running, combined context tokens (self 500 + 830 + 950).
const rendered = text(m.container)
assert.ok(rendered.includes('3 agents'))
assert.ok(rendered.includes('2 running'))
assert.ok(rendered.includes('2.3k tokens in context'))
// The current node wears the brand self-ring and shows the freshest stats.
const self = query(m.container, 'g.lc-agent-self')
assert.equal(self.getAttribute('data-agent'), 'root')
assert.equal(self.getAttribute('role'), 'img')
assert.ok(text(self).includes('50%'))
assert.ok(self.querySelector('.lc-agent-self-badge') !== null)
assert.ok(query(m.container, 'g[data-agent="worker"]').querySelector('.lc-agent-self-badge') === null)
// Links join both children: the running one layers a flowing pulse over the solid lineage stroke.
const links = queryAll(m.container, 'path.lc-agents-link')
assert.equal(links.length, 2)
assert.equal(queryAll(m.container, 'path.lc-agents-link-live').length, 1)
assert.equal(queryAll(m.container, 'path.lc-agents-flow').length, 1)
// The worker node: descriptor label, running halo class, fused ring (composition + free remainder).
const worker = query(m.container, 'g[data-agent="worker"]')
assert.ok(text(worker).includes('worker-bee'))
assert.ok(text(worker).includes('83%'))
assert.ok(worker.classList.contains('lc-agent-running'))
const workerSegs = worker.querySelectorAll('circle.lc-agent-seg')
assert.ok(workerSegs.length > 1)
assert.ok(worker.querySelector('circle.lc-agent-free') !== null)
// The one-shot child: no timeline → id label, pressure-only fused ring (arc + free), done halo class.
const done = query(m.container, 'g[data-agent="done"]')
assert.ok(text(done).includes('95%'))
assert.ok(done.classList.contains('lc-agent-done'))
const doneSegs = done.querySelectorAll('circle.lc-agent-seg')
assert.equal(doneSegs.length, 2)
assert.ok(done.querySelector('circle.lc-agent-free') !== null)
// The inspector mirrors the current node by default, with the self badge.
const inspector = query(m.container, '.lc-agents-inspector')
assert.ok(text(inspector).includes('Main Agent'))
assert.ok(text(inspector).includes('current'))
assert.ok(text(inspector).includes('500 / 1.0k · 50%'))
assert.ok(text(inspector).includes('3 requests'))
assert.ok(text(inspector).includes('1.2k billed'))
assert.ok(!text(inspector).includes('click to open'))
// The legend lists all six categories plus the free-window and running-edge keys.
assert.equal(queryAll(m.container, '.lc-agents-legend-item').length, 8)
// The stage cancels a horizontal swipe it cannot consume, so the browser never reads it as a history swipe
// (jsdom reports zero scroll metrics, so a horizontal-dominant gesture always sits at the edge).
const stage = query(m.container, '.lc-agents-stage')
assert.equal(wheel(stage, 30, 0), true, 'horizontal swipe canceled at the stage edge')
assert.equal(wheel(stage, 30, 120), false, 'vertical-dominant gestures stay with the page')
await m.unmount()
})
test('hover moves the inspector, click/Enter opens the session', async () => {
const face = new FakeSessions(family())
const View = makeView(face)
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
const worker = query(m.container, 'g[data-agent="worker"]')
await hover(worker)
const inspector = query(m.container, '.lc-agents-inspector')
assert.ok(text(inspector).includes('worker-bee'))
assert.ok(text(inspector).includes('continuable'))
assert.ok(text(inspector).includes('5 requests'))
assert.ok(text(inspector).includes('150 billed'))
assert.ok(text(inspector).includes('42s'))
assert.ok(text(inspector).includes('click to open'))
assert.ok(worker.classList.contains('lc-agent-hover'))
await unhover(worker)
assert.ok(text(query(m.container, '.lc-agents-inspector')).includes('Main Agent'))
// Click and keyboard both navigate; the current node never navigates.
await clickEl(worker)
assert.deepEqual(face.opened, ['worker'])
// The one-shot child's inspector shows its mode badge and sample-derived occupancy.
await hover(query(m.container, 'g[data-agent="done"]'))
const doneInspector = query(m.container, '.lc-agents-inspector')
assert.ok(text(doneInspector).includes('one-shot'))
assert.ok(text(doneInspector).includes('950 / 1.0k · 95%'))
await unhover(query(m.container, 'g[data-agent="done"]'))
await act(async () => {
query(m.container, 'g[data-agent="done"]').dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
})
assert.deepEqual(face.opened, ['worker', 'done'])
await act(async () => {
query(m.container, 'g[data-agent="worker"]').dispatchEvent(
new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true }),
)
})
assert.deepEqual(face.opened, ['worker', 'done'])
await clickEl(query(m.container, 'g[data-agent="root"]'))
assert.deepEqual(face.opened, ['worker', 'done'])
await m.unmount()
})
test('list updates re-render the tree live', async () => {
const face = new FakeSessions({ root: { displayTitle: 'Main', running: false, updatedAt: 1 } })
const View = makeView(face)
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
assert.ok(text(m.container).includes('No subagents yet'))
assert.equal(queryAll(m.container, 'g.lc-agent-node').length, 1)
await act(async () => {
face.setState({
root: { displayTitle: 'Main', running: false, updatedAt: 1 },
kid: { parentId: 'root', origin: 'subagent', running: true, updatedAt: 2 },
})
})
await flush()
assert.equal(queryAll(m.container, 'g.lc-agent-node').length, 2)
assert.ok(!text(m.container).includes('No subagents yet'))
assert.ok(text(m.container).includes('2 agents'))
await m.unmount()
})
test('a family with no token data at all hides the totals chip', async () => {
const View = makeView(new FakeSessions({ root: { displayTitle: 'Main', running: false, updatedAt: 1 } }))
const m = await mount(h(View, { sessionId: 'root', self: { head: null, billed: null, requests: 0 } }))
assert.ok(text(m.container).includes('1 agents'))
assert.ok(!text(m.container).includes('tokens in context'))
await m.unmount()
})
test('overflow chip when the family exceeds the cap', async () => {
const byId: Record<string, unknown> = { root: { displayTitle: 'Main', running: false, updatedAt: 1 } }
for (let i = 0; i < 30; i++) byId['kid' + i] = { parentId: 'root', updatedAt: i }
const View = makeView(new FakeSessions(byId))
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
assert.ok(text(m.container).includes('6 more not shown'))
assert.ok(text(m.container).includes('31 agents'))
await m.unmount()
})
test('a rejected catalog refresh is swallowed', async () => {
const face = new FakeSessions(family())
face.rejectRefresh = true
const View = makeView(face)
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
await flush()
assert.deepEqual(face.refreshed, ['root'])
assert.equal(queryAll(m.container, 'g.lc-agent-node').length, 3)
await m.unmount()
})
test('a face without refreshSubagents still renders', async () => {
const face = new FakeSessions(family())
const bare: unknown = { list: face.list, open: (id: string) => face.open(id) }
const View = makeView(bare)
const m = await mount(h(View, { sessionId: 'root' }))
assert.equal(queryAll(m.container, 'g.lc-agent-node').length, 3)
// No self stats: the current node falls back to its list-row timeline (230 tokens ≈ 23%).
const self = query(m.container, 'g.lc-agent-self')
assert.ok(text(self).includes('23%'))
await m.unmount()
})
test('stat-less nodes render dashes; zero occupancy draws no ring', async () => {
const View = makeView(new FakeSessions({
root: { displayTitle: 'Main', running: false, updatedAt: 1 },
bare: { parentId: 'root', origin: 'subagent', updatedAt: 2 },
zero: {
parentId: 'root', origin: 'subagent', updatedAt: 3,
projectionValues: { contextPressure: { projectedTokens: 0, contextWindow: 1000 } },
},
longname: {
parentId: 'root', origin: 'subagent', updatedAt: 4,
projectionValues: { subagent: { mode: 'one-shot', label: 'a-very-long-descriptor-label' } },
},
// Pressure sample without a window: tokens with no denominator, no percentage.
windowless: {
parentId: 'root', origin: 'subagent', updatedAt: 5,
projectionValues: { contextPressure: { pressureTokens: 640 } },
},
// Usage reported but all-zero: the billed bit stays out of the inspector.
flatusage: {
parentId: 'root', origin: 'subagent', updatedAt: 6,
projectionValues: { tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } },
},
}))
const m = await mount(h(View, { sessionId: 'root', self: { head: null, billed: null, requests: 0 } }))
const bare = query(m.container, 'g[data-agent="bare"]')
assert.ok(text(bare).includes('—'))
assert.equal(bare.querySelectorAll('circle.lc-agent-seg').length, 0)
const zero = query(m.container, 'g[data-agent="zero"]')
assert.ok(text(zero).includes('0%'))
// Zero occupancy on a known window: just the free outline.
const zeroSegs = zero.querySelectorAll('circle.lc-agent-seg')
assert.equal(zeroSegs.length, 1)
assert.ok(zero.querySelector('circle.lc-agent-free') !== null)
// Long labels wrap in full — no ellipsis truncation.
assert.ok(text(query(m.container, 'g[data-agent="longname"]')).includes('a-very-long-descriptor-label'))
// The inspector for a stat-less node shows just the identity.
await hover(bare)
const inspector = query(m.container, '.lc-agents-inspector')
assert.ok(text(inspector).includes('bare'))
assert.equal(query(inspector, '.lc-agents-inspector-stats').textContent, '')
// Windowless pressure: a bare token figure, no ' / window' and no percentage.
await hover(query(m.container, 'g[data-agent="windowless"]'))
assert.equal(query(m.container, '.lc-agents-inspector-stats').textContent, '640')
// All-zero usage: no billed bit at all.
await hover(query(m.container, 'g[data-agent="flatusage"]'))
assert.ok(!text(query(m.container, '.lc-agents-inspector')).includes('billed'))
// Self with no stats: no percentage, no chips tokens.
assert.ok(text(query(m.container, 'g.lc-agent-self')).includes('—'))
await m.unmount()
})
test('zh locale renders translated chrome', async () => {
const View = makeView(new FakeSessions(family()), { locale: 'zh' })
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
const rendered = text(m.container)
assert.ok(rendered.includes('Agent 网络'))
assert.ok(rendered.includes('3 个 Agent'))
assert.ok(rendered.includes('当前'))
// Hovering the continuable child surfaces its mode badge in the inspector.
await hover(query(m.container, 'g[data-agent="worker"]'))
assert.ok(text(query(m.container, '.lc-agents-inspector')).includes('多轮'))
await m.unmount()
})
})
describe('ringColorOf', () => {
test('occupancy thresholds', () => {
assert.equal(ringColorOf(null), 'var(--dsw-alias-border-l1)')
assert.equal(ringColorOf(95), '#ef4444')
assert.equal(ringColorOf(70), '#f59e0b')
assert.equal(ringColorOf(12), '#22c55e')
})
})