Split the Agent network card into graph + cursor-following inspector (delegation prompt, latest reply, descriptor, owned tasks, comms), add the members board and the status-grouped task board off the agentTeam projection with inline dependency chains, a filterable/sortable roster, and an on-demand comms timeline over relay/mailbox/settled records. Data rides a new /api/dsh-context/fleet route (one observeSession read per session through the shared cold-read gate); hover, pin, owner filter, and task focus are lifted to the view so every panel steers the same inspector.
244 lines
9.7 KiB
TypeScript
244 lines
9.7 KiB
TypeScript
// fleetModel.ts — the shared family model seat and the fleet-detail read ledger.
|
|
|
|
import { act, createElement as h } from 'react'
|
|
import assert from 'node:assert/strict'
|
|
import { describe, test, vi } from 'vitest'
|
|
import {
|
|
commsStaleOf,
|
|
teamOfSnapshot,
|
|
useFleetDetails,
|
|
useFleetModel,
|
|
type FleetDetailState,
|
|
type FleetModel,
|
|
} from '../../src/client/fleetModel'
|
|
import { FleetDetailCache } from '../../src/client/fleetDetail'
|
|
import type { AgentNode } from '../../src/client/agentTree'
|
|
import type { FleetDetail } from '../../src/shared/types'
|
|
import { TestClientCtx, asClientCtx } from './helpers/harness'
|
|
import { flush, mount } from './helpers/kit'
|
|
import { makeAgentHeads } from '../../src/client/agentHeads'
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
describe('teamOfSnapshot', () => {
|
|
const team = { members: [{ id: 'root', name: 'lead', role: 'lead', phase: 'active' }], tasks: [] }
|
|
|
|
test('the first carrying family row wins — DFS pre-order starts at the root', () => {
|
|
const forest = { nodes: [nodeOf('root'), nodeOf('kid', { parentId: 'root' })], edges: [], overflow: 0, solo: false }
|
|
const snapshot = {
|
|
byId: {
|
|
kid: { projectionValues: { agentTeam: { members: [{ id: 'kid', name: 'k', role: 'lead', phase: 'active' }], tasks: [] } } },
|
|
root: { projectionValues: { agentTeam: team } },
|
|
},
|
|
}
|
|
assert.equal(teamOfSnapshot(snapshot, forest)?.members[0]?.id, 'root')
|
|
})
|
|
|
|
test('degrades: no forest, no byId, no carrier row, or a malformed value', () => {
|
|
const forest = { nodes: [nodeOf('root')], edges: [], overflow: 0, solo: true }
|
|
assert.equal(teamOfSnapshot(null, forest), null)
|
|
assert.equal(teamOfSnapshot({}, forest), null)
|
|
assert.equal(teamOfSnapshot({ byId: null }, forest), null)
|
|
assert.equal(teamOfSnapshot({ byId: { root: {} } }, forest), null)
|
|
assert.equal(teamOfSnapshot({ byId: { root: { projectionValues: { agentTeam: 'junk' } } } }, forest), null)
|
|
assert.equal(teamOfSnapshot({ byId: { root: null } }, forest), null)
|
|
assert.equal(teamOfSnapshot({ byId: { root: { projectionValues: { agentTeam: team } } } }, null), null)
|
|
})
|
|
})
|
|
|
|
describe('commsStaleOf', () => {
|
|
test('a landed read behind the row stamp is stale', () => {
|
|
const landed = new Map<string, { detail: FleetDetail | null; updatedAt: number }>([
|
|
['a', { detail: null, updatedAt: 5 }],
|
|
])
|
|
assert.equal(commsStaleOf([nodeOf('a', { updatedAt: 6 })], landed), true)
|
|
assert.equal(commsStaleOf([nodeOf('a', { updatedAt: 5 })], landed), false)
|
|
assert.equal(commsStaleOf([nodeOf('b', { updatedAt: 99 })], landed), false)
|
|
assert.equal(commsStaleOf([], landed), false)
|
|
})
|
|
})
|
|
|
|
/** A probe component exposing the hook's value to the test. */
|
|
describe('useFleetModel', () => {
|
|
function sessionsFace(byId: Record<string, unknown>, refreshed: string[]) {
|
|
// The snapshot object must stay reference-stable across reads: useSyncExternalStore loops on a fresh one.
|
|
const snapshot = { byId }
|
|
return {
|
|
list: {
|
|
getSnapshot: (): unknown => snapshot,
|
|
subscribe: (): (() => void) => () => {},
|
|
},
|
|
refreshSubagents: (id: string) => {
|
|
refreshed.push(id)
|
|
return Promise.resolve()
|
|
},
|
|
}
|
|
}
|
|
|
|
test('inactive stays inert: no snapshot read, no refresh, no forest', async () => {
|
|
const refreshed: string[] = []
|
|
const ctx = new TestClientCtx({ services: { sessions: sessionsFace({ s1: { running: true, updatedAt: 1 } }, refreshed) } })
|
|
const box: { current: FleetModel | null } = { current: null }
|
|
function Probe(): null {
|
|
box.current = useFleetModel(asClientCtx(ctx), makeAgentHeads(), 's1', undefined, false)
|
|
return null
|
|
}
|
|
const m = await mount(h(Probe))
|
|
assert.equal(box.current?.forest, null)
|
|
assert.equal(box.current?.team, null)
|
|
assert.deepEqual(refreshed, [])
|
|
await m.unmount()
|
|
ctx.dispose()
|
|
})
|
|
|
|
test('active derives the forest and the team, and discovers the child catalog', async () => {
|
|
const refreshed: string[] = []
|
|
const byId = {
|
|
s1: { displayTitle: 'Main', running: true, updatedAt: 1 },
|
|
kid: {
|
|
parentId: 's1', origin: 'subagent', running: true, updatedAt: 2,
|
|
projectionValues: { agentTeam: { members: [{ id: 's1', name: 'lead', role: 'lead', phase: 'active' }], tasks: [] } },
|
|
},
|
|
}
|
|
const ctx = new TestClientCtx({ services: { sessions: sessionsFace(byId, refreshed) } })
|
|
const box: { current: FleetModel | null } = { current: null }
|
|
function Probe(): null {
|
|
box.current = useFleetModel(asClientCtx(ctx), makeAgentHeads(), 's1', undefined, true)
|
|
return null
|
|
}
|
|
const m = await mount(h(Probe))
|
|
assert.equal(box.current?.forest?.nodes.length, 2)
|
|
assert.equal(box.current?.team?.members[0]?.name, 'lead')
|
|
assert.deepEqual(refreshed, ['s1'])
|
|
await m.unmount()
|
|
ctx.dispose()
|
|
})
|
|
|
|
test('a rejecting catalog refresh is swallowed', async () => {
|
|
const snapshot = { byId: { s1: { running: true, updatedAt: 1 } } }
|
|
const ctx = new TestClientCtx({
|
|
services: {
|
|
sessions: {
|
|
list: { getSnapshot: () => snapshot, subscribe: () => () => {} },
|
|
refreshSubagents: () => Promise.reject(new Error('nope')),
|
|
},
|
|
},
|
|
})
|
|
const box: { current: FleetModel | null } = { current: null }
|
|
function Probe(): null {
|
|
box.current = useFleetModel(asClientCtx(ctx), makeAgentHeads(), 's1', undefined, true)
|
|
return null
|
|
}
|
|
const m = await mount(h(Probe))
|
|
assert.ok(box.current?.forest !== null)
|
|
await flush()
|
|
await m.unmount()
|
|
ctx.dispose()
|
|
})
|
|
})
|
|
|
|
describe('useFleetDetails', () => {
|
|
function probeDetails(cache: FleetDetailCache): { box: { current: FleetDetailState | null }; Probe: () => null } {
|
|
const box: { current: FleetDetailState | null } = { current: null }
|
|
function Probe(): null {
|
|
box.current = useFleetDetails(cache)
|
|
return null
|
|
}
|
|
return { box, Probe }
|
|
}
|
|
|
|
function detailCache(serve: (id: string) => FleetDetail | null): FleetDetailCache {
|
|
const cache = new FleetDetailCache()
|
|
vi.spyOn(cache, 'fetch').mockImplementation((id: string) => Promise.resolve(serve(id)))
|
|
return cache
|
|
}
|
|
|
|
test('request lands the detail, dedupes by stamp, and pending drains', async () => {
|
|
const cache = detailCache(id => ({ messages: [], truncated: false, initialPrompt: `prompt of ${id}` }))
|
|
const { box, Probe } = probeDetails(cache)
|
|
const m = await mount(h(Probe))
|
|
const state = box.current as FleetDetailState
|
|
|
|
act(() => { state.request('a', 1) })
|
|
assert.equal(box.current?.pending.has('a'), true)
|
|
await act(async () => {})
|
|
assert.equal(box.current?.pending.size, 0)
|
|
assert.equal(box.current?.landed.get('a')?.detail?.initialPrompt, 'prompt of a')
|
|
|
|
// Same or older stamp: no new read.
|
|
act(() => { state.request('a', 1) })
|
|
assert.equal(box.current?.pending.size, 0)
|
|
// A newer stamp refetches.
|
|
act(() => { state.request('a', 2) })
|
|
await act(async () => {})
|
|
assert.equal(box.current?.landed.get('a')?.updatedAt, 2)
|
|
await m.unmount()
|
|
})
|
|
|
|
test('two in-flight reads for one session settle into one pending drain', async () => {
|
|
const cache = new FleetDetailCache()
|
|
const releases: Array<(d: FleetDetail | null) => void> = []
|
|
vi.spyOn(cache, 'fetch').mockImplementation(() => new Promise((resolve) => { releases.push(resolve) }))
|
|
const { box, Probe } = probeDetails(cache)
|
|
const m = await mount(h(Probe))
|
|
const state = box.current as FleetDetailState
|
|
|
|
// A fresher stamp mid-flight re-reads but never double-marks pending.
|
|
act(() => { state.request('a', 1) })
|
|
act(() => { state.request('a', 2) })
|
|
assert.equal(box.current?.pending.size, 1)
|
|
// The older read lands first: pending drains (the newer read is untracked by design — its
|
|
// landing settles the ledger line), then the newer read's landing takes the newer stamp.
|
|
await act(async () => { releases[0](null) })
|
|
assert.equal(box.current?.pending.size, 0)
|
|
assert.equal(box.current?.landed.get('a')?.updatedAt, 1)
|
|
await act(async () => { releases[1]({ messages: [], truncated: false }) })
|
|
assert.equal(box.current?.pending.size, 0)
|
|
assert.equal(box.current?.landed.get('a')?.updatedAt, 2)
|
|
await m.unmount()
|
|
})
|
|
|
|
test('scan requests every node; refresh re-reads them', async () => {
|
|
let reads = 0
|
|
const cache = new FleetDetailCache()
|
|
vi.spyOn(cache, 'fetch').mockImplementation(() => {
|
|
reads += 1
|
|
return Promise.resolve(null)
|
|
})
|
|
const { box, Probe } = probeDetails(cache)
|
|
const m = await mount(h(Probe))
|
|
const state = box.current as FleetDetailState
|
|
const nodes = [nodeOf('a', { updatedAt: 1 }), nodeOf('b', { updatedAt: 1 })]
|
|
|
|
act(() => { state.scan(nodes) })
|
|
await act(async () => {})
|
|
assert.equal(reads, 2)
|
|
assert.equal(box.current?.landed.size, 2)
|
|
|
|
act(() => { box.current?.refresh(nodes) })
|
|
await act(async () => {})
|
|
assert.equal(reads >= 3, true)
|
|
await m.unmount()
|
|
})
|
|
|
|
test('a landing after unmount updates nothing', async () => {
|
|
const cache = new FleetDetailCache()
|
|
let release: (d: FleetDetail | null) => void = () => {}
|
|
vi.spyOn(cache, 'fetch').mockImplementation(() => new Promise((resolve) => { release = resolve }))
|
|
const { box, Probe } = probeDetails(cache)
|
|
const m = await mount(h(Probe))
|
|
const state = box.current as FleetDetailState
|
|
act(() => { state.request('a', 1) })
|
|
await m.unmount()
|
|
release(null)
|
|
await flush()
|
|
assert.equal(box.current?.landed.size, 0)
|
|
})
|
|
})
|