Files
dsh-context/tests/client/components/agentGraph.spec.ts
T
Bowen Liang ede62413e6 fix(client): replay the cached cold-relative head across graph remounts
The fetched composition heads lived in component state while the promise
cache lived in the factory closure, so a remount (tab switch) reset the
composed rings back to green and the cache hit kept every relative from
ever re-fetching. A cached read now replays into the fresh instance, with
an identity bail-out so the settled replay on every snapshot tick cannot
loop. Also drops a redundant route wrapper and aligns the spec helper with
the fetch transport it stubs.
2026-09-15 08:52:16 +08:00

581 lines
25 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 { afterEach, describe, test, vi } 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, 9)
// 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), 'var(--color-red-500)')
assert.equal(ringColorOf(70), 'var(--color-amber-500)')
assert.equal(ringColorOf(12), 'var(--color-green-500)')
})
})
describe('AgentGraph — cold-relative composition fetch', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
/** A multi-category slim head (the detail payload's cold-node field). */
function composedHead(): unknown {
return {
ok: true,
contextWindow: 1000,
current: { system: 100, tools: 50, user: 200, inject: 50, skill: 0, assistant: 150, tool: 50, total: 600 },
requests: [],
events: [],
nodes: [],
droppedNodes: 0,
archive: [],
}
}
/** A detail payload without the head (the collections-only shape). */
function detailValue(head: unknown): Record<string, unknown> {
return {
rev: 1,
...(head !== undefined ? { head } : {}),
requests: [],
events: [],
nodes: [],
droppedNodes: 0,
archive: [],
}
}
/**
* A programmable global fetch recording its reads: POSTs resolve `value`
* (or throw, or hold until released).
*/
function detailFetch(options: { reject?: boolean; head?: unknown; nullValue?: boolean; defer?: boolean } = {}) {
const calls: string[] = []
let release: ((value: unknown) => void) | undefined
vi.stubGlobal('fetch', async (url: unknown, init: { body: string }) => {
const sessionId = (JSON.parse(String(init?.body)) as { sessionId?: unknown }).sessionId
calls.push(`${String(url)}:${String(sessionId)}`)
if (options.reject) throw new Error('transport down')
if (options.defer) {
return await new Promise((resolve) => { release = resolve })
}
return {
ok: true,
status: 200,
json: async () => ({ ok: true, value: options.nullValue ? null : detailValue(options.head) }),
}
})
return { calls, release: (value: unknown) => release?.(value) }
}
function makeFetchingView(): { View: ReturnType<typeof makeView>; face: FakeSessions } {
const face = new FakeSessions(family())
const ctx = new TestClientCtx({ services: { sessions: face } })
return { View: makeAgentGraph(asClientCtx(ctx), kit), face }
}
test('pressure-only relatives fetch their head and re-render composed', async () => {
const rpc = detailFetch({ defer: true })
const { View, face } = makeFetchingView()
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
// The cold relative fetched at mount; until the read lands it wears the
// pressure-only fused ring (arc + free outline).
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
assert.equal(query(m.container, 'g[data-agent="done"]').querySelectorAll('circle.lc-agent-seg').length, 2)
// A snapshot tick while the read is in flight re-attaches the SAME
// pending read; its duplicate landing settles on the identity bail-out
// instead of re-rendering.
await act(async () => {
face.setState(family())
})
await flush()
await act(async () => {
rpc.release({ ok: true, status: 200, json: async () => ({ ok: true, value: detailValue(composedHead()) }) })
})
await flush()
// The head re-folds the ring: six composition arcs + the free remainder.
const composed = query(m.container, 'g[data-agent="done"]')
assert.equal(composed.querySelectorAll('circle.lc-agent-seg').length, 7)
assert.ok(text(composed).includes('95%'), 'the pressure-anchored occupancy never changes')
// A snapshot tick re-fetches nothing (the promise cache dedups).
await act(async () => {
face.setState(family())
})
await flush()
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
// A remount (tab switch) resets the instance state but replays the
// factory-cached read: the ring recomposes with no new request.
await m.unmount()
const remounted = await mount(h(View, { sessionId: 'root', self: selfStats() }))
await flush()
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'], 'the cache never re-fetches')
const replayed = query(remounted.container, 'g[data-agent="done"]')
assert.equal(replayed.querySelectorAll('circle.lc-agent-seg').length, 7, 'the cached head replays into the fresh instance')
await remounted.unmount()
})
test('a failing fetch degrades to the pressure-only ring and never retries', async () => {
const rpc = detailFetch({ reject: true })
const { View, face } = makeFetchingView()
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
await flush()
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
assert.equal(query(m.container, 'g[data-agent="done"]').querySelectorAll('circle.lc-agent-seg').length, 2)
// A later snapshot tick (fresh forest, still pressure-only) re-fetches nothing.
await act(async () => {
face.setState(family())
})
await flush()
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'], 'the sticky failure never re-fetches')
await m.unmount()
})
test('a hostile head drops alone and a null value degrades the same way', async () => {
for (const options of [{ head: 'garbage' }, { nullValue: true }]) {
const rpc = detailFetch(options)
const { View } = makeFetchingView()
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
await flush()
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:done'])
assert.equal(query(m.container, 'g[data-agent="done"]').querySelectorAll('circle.lc-agent-seg').length, 2)
assert.ok(text(query(m.container, 'g[data-agent="done"]')).includes('95%'))
await m.unmount()
}
})
test('a hostile empty session id degrades to the pressure-only ring without fetching', async () => {
const rpc = detailFetch({ head: composedHead() })
const face = new FakeSessions({
root: { displayTitle: 'Main', running: false, updatedAt: 10 },
'': {
parentId: 'root', origin: 'subagent', updatedAt: 2,
projectionValues: { contextPressure: { projectedTokens: 400, contextWindow: 1000 } },
},
})
const ctx = new TestClientCtx({ services: { sessions: face } })
const View = makeAgentGraph(asClientCtx(ctx), kit)
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
await flush()
assert.deepEqual(rpc.calls, [], 'an empty id never opens the detail route')
assert.equal(query(m.container, 'g[data-agent=""]').querySelectorAll('circle.lc-agent-seg').length, 2)
await m.unmount()
})
test('a head with no occupancy anchor still composes from the fold alone', async () => {
// No pressure on the row: the fetched head's fold total and window carry the ring.
const rpc = detailFetch({ head: composedHead(), defer: true })
const face = new FakeSessions({
root: { displayTitle: 'Main', running: false, updatedAt: 10 },
cold: {
parentId: 'root', origin: 'subagent', updatedAt: 2,
projectionValues: { subagent: { mode: 'one-shot', label: 'chill' } },
},
})
const ctx = new TestClientCtx({ services: { sessions: face } })
const View = makeAgentGraph(asClientCtx(ctx), kit)
const m = await mount(h(View, { sessionId: 'root', self: selfStats() }))
assert.equal(query(m.container, 'g[data-agent="cold"]').querySelectorAll('circle.lc-agent-seg').length, 0)
assert.deepEqual(rpc.calls, ['/api/dsh-context/detail:cold'])
await act(async () => {
rpc.release({ ok: true, status: 200, json: async () => ({ ok: true, value: detailValue(composedHead()) }) })
})
await flush()
const composed = query(m.container, 'g[data-agent="cold"]')
assert.equal(composed.querySelectorAll('circle.lc-agent-seg').length, 7, 'six arcs + the free remainder (no pressure arc)')
assert.ok(text(composed).includes('60%'), 'the fold total prices against the head window')
await m.unmount()
})
})