Files
deepseek-harness/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx
T

972 lines
39 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { makeTranslate, RemoteError, sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime'
import type {
SessionListState, SessionSummary, SessionSnapshot,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SubagentAddress, SubagentCatalogRow } from '@deepseek-ai/dsh-subagent/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import {
SubagentHeaderLineage, type SubagentHeaderLineageProps,
} from '../src/client/SubagentHeaderLineage.tsx'
import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
const PARENT = 'parent' as SessionId
const CHILD = 'child' as SessionId
const GRANDCHILD = 'grandchild' as SessionId
const t: SubagentHeaderLineageProps['t'] = makeTranslate(zh)
type CatalogFixture = { entries: readonly (SubagentCatalogRow | { id: SessionId; mode: 'unknown'; label?: string; activity: 'inactive' })[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] }
function catalog(over: Partial<CatalogFixture> = {}): CatalogFixture {
return {
entries: [
{
id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running',
},
{
id: 'child-2' as SessionId, mode: 'one-shot',
label: 'reviewer', activity: 'inactive',
},
],
parentAvailable: true,
state: 'ready',
error: null,
...over,
}
}
function props(
value: CatalogFixture | undefined,
nested: Readonly<Record<SessionId, CatalogFixture>> = {},
summaries?: Readonly<Record<SessionId, SessionSummary>>,
boundAddress?: SubagentAddress,
) {
const catalogs = value === undefined ? nested : { [PARENT]: value, ...nested }
const statuses: SessionStatusSnapshot = new Map()
const state = {
ids: [CHILD],
byId: summaries ?? {
...Object.fromEntries(Object.values(catalogs).flatMap(catalog => catalog.entries.map(entry => [entry.id, {
id: entry.id, displayTitle: entry.label ?? entry.id, running: entry.activity === 'running', blank: false, updatedAt: 1, retainedBy: {},
}]))),
[CHILD]: {
id: CHILD,
title: '正在扫描项目文件',
displayTitle: 'worker',
running: Object.values(catalogs).some(catalog => catalog.entries.some(entry => entry.id === CHILD && entry.activity === 'running')),
retainedBy: {},
blank: false,
updatedAt: Date.now(),
},
},
phase: 'ready',
projectionsBySession: Object.fromEntries(Object.entries(catalogs).map(([id, catalog]) => [id, {
state: catalog.state, error: catalog.error,
values: { subagentCatalog: catalog.entries.map(({ activity: _activity, ...entry }) => ({ ...entry, createdAt: 1 })) },
}])) ,
jobsBySession: {},
} satisfies SessionListState
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
return select(state)
}
const snapshot: SessionSnapshot = {
...sessionSnapshot(PARENT),
subagent: boundAddress === undefined ? null : { address: boundAddress },
}
const unused = (): never => { throw new Error('Subagent header fixture does not provide this slot source or action') }
const standard = {
usePanelInfo: unused,
useSessionRetainInfo: unused,
useWorkspaces: unused,
useResource: unused,
useProjection: unused,
useConversation: unused,
useInput: unused,
useChat: unused,
useTrajectory: unused,
inputActions: {
captureInsertion: unused,
insertText: unused,
setDraft: unused,
addAttachments: unused,
removeAttachment: unused,
pruneAttachments: unused,
submit: unused,
},
}
return {
...standard,
sessionId: PARENT,
useSessions,
useSessionStatus: <T,>(select: (snapshot: SessionStatusSnapshot) => T): T => select(statuses),
useSession: <T,>(select: (snapshot: SessionSnapshot) => T): T => select(snapshot),
openChild: vi.fn(),
openChildAside: vi.fn(),
refreshProjection: vi.fn(),
lineageSessionId: PARENT,
displayTitle: 'Parent title',
t,
} satisfies SubagentHeaderLineageProps
}
function summary(id: SessionId, updatedAt: number): SessionSummary {
return {
id,
displayTitle: id,
running: false,
retainedBy: {},
blank: false,
updatedAt,
}
}
function hoverCatalog(trigger: HTMLElement): void {
const root = trigger.parentElement
if (root === null) throw new Error('catalog trigger has no root')
vi.useFakeTimers()
fireEvent.mouseEnter(root)
act(() => { vi.advanceTimersByTime(150) })
}
describe('SubagentHeaderLineage', () => {
it('shows current catalog-only child status before Host list discovery', () => {
const input = props(catalog({ entries: [{ id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive' }] }))
const initial = input.useSessions(state => state)
const statuses: SessionStatusSnapshot = new Map([[CHILD, {
running: true, completionUnread: false, pendingInteraction: undefined,
}]])
render(<SubagentHeaderLineage {...input}
useSessions={select => select({ ...initial, ids: [] })}
useSessionStatus={select => select(statuses)}
/>)
expect(screen.getByRole('button', { name: '1 个子代理,正在运行' })).toBeTruthy()
})
it('shows retained children while an idle projection awaits refresh', () => {
const injected = props(catalog())
const initial = injected.useSessions(state => state)
let projections: SessionListState['projectionsBySession'] = {
[PARENT]: { state: 'idle', error: null, values: {} },
}
const current: SubagentHeaderLineageProps = {
...injected,
useSessions: select => select({ ...initial, projectionsBySession: projections }),
}
const view = render(<SubagentHeaderLineage {...current} />)
expect(screen.queryByRole('button')).toBeNull()
projections = {
[PARENT]: { ...initial.projectionsBySession[PARENT]!, state: 'idle' },
}
view.rerender(<SubagentHeaderLineage {...current} />)
hoverCatalog(screen.getByRole('button'))
expect(screen.getByRole('treeitem', { name: /worker/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /reviewer/ })).toBeTruthy()
})
it('combines projected membership with Session activity on the closed trigger', () => {
const summaries: Record<SessionId, SessionSummary> = {
[CHILD]: {
...summary(CHILD, Date.now()), running: true,
parentId: PARENT,
origin: 'subagent',
},
[GRANDCHILD]: {
...summary(GRANDCHILD, Date.now()),
parentId: CHILD,
origin: 'subagent',
running: true,
},
['child-2' as SessionId]: {
...summary('child-2' as SessionId, Date.now()),
parentId: PARENT,
origin: 'subagent',
},
}
const view = render(<SubagentHeaderLineage {...props(catalog(), {}, summaries)} />)
const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull()
view.rerender(<SubagentHeaderLineage {...props(catalog(), {}, {
...summaries, [CHILD]: { ...summaries[CHILD]!, running: false },
})} />)
const inactiveTrigger = screen.getByRole('button', { name: '2 个子代理' })
expect(inactiveTrigger.querySelector('[data-state="ongoing"]')).toBeNull()
})
it('does not count Sessions outside projected membership', () => {
const fork = 'fork' as SessionId
const forkChild = 'fork-child' as SessionId
render(<SubagentHeaderLineage {...props(catalog({
entries: [{
id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive',
}],
}), {}, {
[CHILD]: { ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' },
['child-2' as SessionId]: {
...summary('child-2' as SessionId, 1), parentId: PARENT, origin: 'subagent',
},
[fork]: { ...summary(fork, 1), parentId: PARENT },
[forkChild]: { ...summary(forkChild, 1), parentId: fork, origin: 'subagent', running: true },
})} />)
const trigger = screen.getByRole('button', { name: '1 个子代理' })
expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull()
})
it('renders stable rows and catalog-addressed navigation', () => {
const input = props(catalog())
render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
hoverCatalog(trigger)
expect(input.refreshProjection).not.toHaveBeenCalled()
expect(screen.getAllByRole('treeitem')).toHaveLength(2)
expect(screen.getByText('正在扫描项目文件 · 可继续 · 正在运行')).toBeTruthy()
expect(screen.getByText('一次性 · 当前未运行')).toBeTruthy()
expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy()
expect(screen.getByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeTruthy()
const sidebarButton = screen.getByRole('button', { name: '在侧边栏打开 worker' })
fireEvent.keyDown(sidebarButton, { key: 'Enter' })
fireEvent.click(sidebarButton)
expect(input.openChildAside).toHaveBeenCalledWith({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
})
hoverCatalog(trigger)
fireEvent.click(screen.getByRole('treeitem', { name: /worker/ }))
expect(input.openChild).toHaveBeenCalledWith({
parentSessionId: PARENT,
childSessionId: CHILD, mode: 'continuable',
})
})
it('selects singular count keys for one direct child', () => {
const base = props(catalog({
entries: [{
id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running',
}],
}), {}, {
[CHILD]: {
...summary(CHILD, Date.now()), parentId: PARENT, origin: 'subagent', running: true,
},
})
const translate = vi.fn(base.t)
render(<SubagentHeaderLineage {...base} t={translate} />)
expect(translate).toHaveBeenCalledWith('count.running.one', { count: 1 })
expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 })
})
it('removes the disclosure column from branchless catalog levels', () => {
render(<SubagentHeaderLineage {...props(catalog({
entries: catalog().entries.slice(0, 1),
}), { [CHILD]: catalog({ entries: [] }) })} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理/ }))
expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(1)
})
it('aligns a known leaf with siblings whose child catalog is still unknown', () => {
render(<SubagentHeaderLineage {...props(catalog(), { [CHILD]: catalog({ entries: [] }) })} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(2)
expect(screen.getByRole('button', { name: /展开 reviewer/ })).toBeTruthy()
})
it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
const input = props(catalog())
render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /worker/ }))
fireEvent.keyDown(document.activeElement as Element, { key: 'End' })
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
fireEvent.keyDown(document.activeElement as Element, { key: 'Home' })
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /worker/ }))
fireEvent.keyDown(document.activeElement as Element, { key: 'ArrowUp' })
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
fireEvent.keyDown(document.activeElement as Element, { key: 'Escape' })
await Promise.resolve()
expect(screen.queryByRole('tree')).toBeNull()
expect(document.activeElement).toBe(trigger)
hoverCatalog(trigger)
fireEvent.pointerDown(screen.getByRole('tree'))
expect(screen.getByRole('tree')).toBeTruthy()
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('tree')).toBeNull()
})
it('opens only on hover and preserves the portaled-menu crossing grace', async () => {
vi.useFakeTimers()
const advance = async (duration: number): Promise<void> => {
await act(async () => { await vi.advanceTimersByTimeAsync(duration) })
}
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
const triggerRect = vi.spyOn(trigger, 'getBoundingClientRect')
.mockReturnValue({ bottom: 40, left: 50 } as DOMRect)
fireEvent.click(trigger)
expect(screen.queryByRole('tree')).toBeNull()
fireEvent.mouseEnter(trigger.parentElement!)
await advance(149)
expect(screen.queryByRole('tree')).toBeNull()
await advance(1)
const tree = screen.getByRole('tree')
expect(tree.style.top).toBe('45px')
expect(tree.style.left).toBe('50px')
triggerRect.mockReturnValue({ bottom: 60, left: 70 } as DOMRect)
fireEvent.resize(window)
expect(tree.style.top).toBe('65px')
expect(tree.style.left).toBe('70px')
fireEvent.mouseLeave(trigger.parentElement!)
fireEvent.mouseEnter(tree)
await advance(120)
expect(screen.getByRole('tree')).toBeTruthy()
fireEvent.mouseLeave(tree)
await advance(119)
expect(screen.getByRole('tree')).toBeTruthy()
await advance(1)
expect(screen.queryByRole('tree')).toBeNull()
hoverCatalog(trigger)
fireEvent.mouseLeave(trigger.parentElement!)
view.unmount()
await advance(120)
})
it('repositions an open catalog after viewport resize and document scroll', () => {
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
const bounds = vi.spyOn(trigger, 'getBoundingClientRect')
bounds.mockReturnValue({ bottom: 20, left: 30 } as DOMRect)
hoverCatalog(trigger)
const tree = screen.getByRole('tree')
expect(tree.style.top).toBe('25px')
expect(tree.style.left).toBe('30px')
bounds.mockReturnValue({ bottom: 70, left: 80 } as DOMRect)
act(() => { window.dispatchEvent(new Event('resize')) })
expect(tree.style.top).toBe('75px')
expect(tree.style.left).toBe('80px')
bounds.mockReturnValue({ bottom: 90, left: 100 } as DOMRect)
act(() => { document.dispatchEvent(new Event('scroll')) })
expect(tree.style.top).toBe('95px')
expect(tree.style.left).toBe('100px')
view.unmount()
})
it('cancels a pending hover when the trigger becomes hidden', async () => {
vi.useFakeTimers()
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.mouseEnter(trigger.parentElement!)
await vi.advanceTimersByTimeAsync(149)
view.rerender(<SubagentHeaderLineage {...props(catalog({ entries: [] }))} />)
expect(screen.queryByRole('button')).toBeNull()
await vi.advanceTimersByTimeAsync(1)
fireEvent.resize(window)
expect(screen.queryByRole('tree')).toBeNull()
})
it('covers fallback labels and keyboard row activation', () => {
const unlabeled = 'unlabeled' as SessionId
const input = props(catalog({
entries: [
{
id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running',
},
{
id: unlabeled, mode: 'one-shot',
activity: 'inactive',
},
],
}))
render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.keyDown(trigger, { key: 'Tab' })
expect(screen.queryByRole('tree')).toBeNull()
hoverCatalog(trigger)
fireEvent.keyDown(screen.getByRole('treeitem', { name: /worker/ }), { key: 'Enter' })
expect(input.openChild).toHaveBeenLastCalledWith({
parentSessionId: PARENT,
childSessionId: CHILD, mode: 'continuable',
})
hoverCatalog(trigger)
fireEvent.keyDown(screen.getByRole('treeitem', { name: /unlabeled/ }), { key: ' ' })
expect(input.openChild).toHaveBeenLastCalledWith({
parentSessionId: PARENT,
childSessionId: unlabeled, mode: 'one-shot',
})
})
it('keeps unknown catalog children clickable by their durable parent address', () => {
const input = props(catalog({ entries: [{ id: CHILD, mode: 'unknown', activity: 'inactive' }] }))
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: /子代理/ }))
const row = screen.getByRole('treeitem', { name: new RegExp(CHILD) })
expect(row.textContent).toContain('模式未知')
fireEvent.keyDown(row, { key: 'Enter' })
expect(input.openChild).toHaveBeenCalledWith({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'unknown' })
})
it('shows durable completion and token totals, ticks active duration, and freezes inactive rows', async () => {
const now = 2_000_000_000_000
const minute = 60_000
const hour = 60 * minute
const day = 24 * hour
vi.useFakeTimers()
vi.setSystemTime(now)
const rows = [
['running', 'running', 65_000, now - 5_000, now - 1_000, now],
['finished', 'inactive', 3_723_000, undefined, undefined, now - 60_000],
['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000, now + 60_000],
['days', 'inactive', 12 * day + 5 * hour + 6 * minute + 7_000, undefined, undefined, now],
['whole-day', 'inactive', day, undefined, undefined, now],
['months', 'inactive', 192 * day, undefined, undefined, now],
['whole-month', 'inactive', 30 * day, undefined, undefined, now],
['years', 'inactive', 832 * day, undefined, undefined, now],
['whole-year', 'inactive', 365 * day, undefined, undefined, now],
] as const
const usageById = {
running: {
uncachedInputTokens: 1_000,
outputTokens: 200,
cacheReadTokens: 3_000,
cacheWriteTokens: 400,
},
finished: {
uncachedInputTokens: 123,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
interrupted: {
uncachedInputTokens: 123_000_000,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
} as const
const entries = rows.map(([id, activity]) => ({
id: id as SessionId,
mode: 'continuable' as const,
label: id,
activity,
}))
const summaries = Object.fromEntries(rows.map(([
id, activity, settledMs, activeSince, activeThrough, updatedAt,
]) => {
const childId = id as SessionId
return [id, {
...summary(childId, updatedAt),
parentId: PARENT,
origin: 'subagent' as const,
running: activity === 'running',
projectionValues: {
subagentTiming: {
settledMs,
...(id === 'finished'
? { lastTurnCompleted: true }
: id === 'interrupted'
? { lastTurnCompleted: false }
: {}),
...(activeSince === undefined || activeThrough === undefined
? {}
: { active: { since: activeSince, through: activeThrough } }),
},
tokenUsage: id in usageById
? usageById[id as keyof typeof usageById]
: undefined,
},
}]
})) as Record<SessionId, SessionSummary>
const input = props(catalog({ entries }), {}, summaries)
render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
expect(within(trigger).getByText('9 个子代理')).toBeTruthy()
hoverCatalog(trigger)
const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ })
const runningMetrics = within(runningRow)
const tokenMetric = runningMetrics.getByText('4.6K tok')
const durationMetric = runningMetrics.getByText('1分10秒')
expect(tokenMetric.parentElement).toBe(durationMetric.parentElement)
expect(tokenMetric.nextElementSibling).toBe(durationMetric)
const finishedRow = screen.getByRole('treeitem', { name: /finished.*已完成.*123 tok · 1小时02分03秒/ })
expect(finishedRow.querySelector('[data-state="done"]')).not.toBeNull()
const interruptedRow = screen.getByRole('treeitem', { name: /interrupted.*当前未运行.*123M tok · 6秒/ })
expect(interruptedRow.querySelector('[data-state="idle"]')).not.toBeNull()
for (const row of [runningRow, finishedRow, interruptedRow]) {
expect(row.querySelector('[data-state]')?.parentElement?.className).toContain('rowActivitySlot')
}
expect(screen.getByRole('treeitem', { name: /days.*12天05小时06分07秒/ })).toBeTruthy()
expect(screen.getByText('12天5小时').getAttribute('title'))
.toBe('总活跃耗时:12天05小时06分07秒')
expect(screen.getByText('1天')).toBeTruthy()
expect(screen.getByText('约6个月12天')).toBeTruthy()
expect(screen.getByText('约1个月')).toBeTruthy()
expect(screen.getByText('约2年3个月')).toBeTruthy()
expect(screen.getByText('约1年')).toBeTruthy()
await vi.advanceTimersByTimeAsync(1_000)
expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /finished.*已完成.*123 tok · 1小时02分03秒/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /interrupted.*当前未运行.*123M tok · 6秒/ })).toBeTruthy()
})
it('ticks expanded running grandchildren under an idle child and releases their clock on collapse', async () => {
const now = 2_000_000_000_000
vi.useFakeTimers()
vi.setSystemTime(now)
const input = props(catalog({ entries: [{
id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive',
}] }), {
[CHILD]: catalog({ entries: [{
id: GRANDCHILD, mode: 'continuable', label: 'indexer', activity: 'running',
}] }),
}, {
[GRANDCHILD]: {
...summary(GRANDCHILD, now),
parentId: CHILD,
origin: 'subagent',
running: true,
projectionValues: {
subagentTiming: { settledMs: 0, active: { since: now - 5_000, through: now } },
},
},
})
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: '1 个子代理' }))
const beforeExpand = vi.getTimerCount()
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(screen.getByRole('treeitem', { name: /indexer.*5秒/ })).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
expect(screen.getByRole('treeitem', { name: /indexer.*6秒/ })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子代理' }))
expect(vi.getTimerCount()).toBe(beforeExpand)
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(screen.getByRole('treeitem', { name: /indexer.*7秒/ })).toBeTruthy()
})
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {
const childCatalog = catalog({
entries: [
{
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
},
],
})
const grandchildCatalog = catalog({ entries: [] })
const input = props(catalog(), {
[CHILD]: childCatalog,
[GRANDCHILD]: grandchildCatalog,
})
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(input.refreshProjection).toHaveBeenCalledWith(CHILD)
const nested = screen.getByRole('treeitem', { name: /indexer/ })
expect(nested.getAttribute('aria-level')).toBe('2')
fireEvent.click(nested)
expect(input.openChild).toHaveBeenCalledWith({
parentSessionId: CHILD,
childSessionId: GRANDCHILD, mode: 'continuable',
})
})
it('shows one generic state while a child catalog loads', () => {
const secondGrandchild = 'grandchild-2' as SessionId
const summaries = {
[CHILD]: { ...summary(CHILD, 1), running: true },
[GRANDCHILD]: {
...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const,
},
[secondGrandchild]: {
...summary(secondGrandchild, 1), parentId: CHILD, origin: 'subagent' as const,
running: true,
},
}
const deferred = props(catalog(), {}, summaries)
const view = render(<SubagentHeaderLineage {...deferred} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(deferred.refreshProjection).toHaveBeenCalledWith(CHILD)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true')
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
expect(screen.queryByRole('treeitem', { name: '正在加载子代理' })).toBeNull()
const loading = props(catalog(), {
[CHILD]: catalog({ entries: [], state: 'loading' }),
}, summaries)
view.rerender(<SubagentHeaderLineage {...loading} />)
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
const ready = props(catalog(), {
[CHILD]: catalog({
entries: [
{
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
},
{
id: secondGrandchild, mode: 'one-shot',
label: 'critic', activity: 'running',
},
],
}),
}, summaries)
view.rerender(<SubagentHeaderLineage {...ready} />)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull()
expect(screen.getByRole('treeitem', { name: /indexer/ }).querySelector('[data-state="idle"]')).not.toBeNull()
expect(screen.getByRole('treeitem', { name: /critic/ }).querySelector('[data-state="ongoing"]')).not.toBeNull()
expect(screen.queryByText('正在加载子代理…')).toBeNull()
})
it('uses ArrowRight and ArrowLeft for branch disclosure', async () => {
const input = props(catalog(), {
[CHILD]: catalog({
entries: [{
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'running',
}],
}),
})
render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
const worker = screen.getByRole('treeitem', { name: /worker/ })
fireEvent.keyDown(worker, { key: 'ArrowRight' })
expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy()
fireEvent.keyDown(worker, { key: 'ArrowLeft' })
expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull()
})
it('closes expanded descendants even when their own catalogs have not arrived', () => {
const input = props(catalog(), {
[CHILD]: catalog({
entries: [{
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'running',
}],
}),
})
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
fireEvent.click(screen.getByRole('button', { name: '展开 indexer 的下级子代理' }))
fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子代理' }))
expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull()
})
it('hides an arrived empty catalog and exposes retry for a failed one', () => {
const absent = render(<SubagentHeaderLineage {...props(undefined)} />)
expect(screen.queryByRole('button')).toBeNull()
absent.unmount()
const empty = props(catalog({ entries: [] }))
const view = render(<SubagentHeaderLineage {...empty} />)
expect(screen.queryByRole('button')).toBeNull()
view.unmount()
const failed = props(catalog({
entries: [],
state: 'error',
error: new RemoteError('gateway/internal', 'index down', {}),
}))
render(<SubagentHeaderLineage {...failed} />)
hoverCatalog(screen.getByRole('button', { name: /0 个子代理/ }))
expect(screen.getByText('index down')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /重试/ }))
expect(failed.refreshProjection).toHaveBeenCalledWith(PARENT)
})
it('does not expose summary-only descendants as catalog rows', () => {
const second = 'child-2' as SessionId
const summaries = {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
},
[second]: {
...summary(second, 1), parentId: PARENT, origin: 'subagent' as const, running: true,
},
}
const absent = props(undefined, {}, summaries)
const view = render(<SubagentHeaderLineage {...absent} />)
expect(screen.queryByRole('button')).toBeNull()
const staleEmpty = props(catalog({ entries: [] }), {}, summaries)
view.rerender(<SubagentHeaderLineage {...staleEmpty} />)
expect(screen.queryByRole('button')).toBeNull()
})
it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => {
// A loading snapshot without evidence of children must not flash the action in.
const loading = props(catalog({ entries: [], state: 'loading' }))
const view = render(<SubagentHeaderLineage {...loading} />)
expect(screen.queryByRole('button')).toBeNull()
view.unmount()
const failed = props(catalog({ entries: [], state: 'error', error: null }))
render(<SubagentHeaderLineage {...failed} />)
const trigger = screen.getByRole('button', { name: /0 个子代理/ })
hoverCatalog(trigger)
expect(screen.getByText('无法加载子代理')).toBeTruthy()
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
expect(screen.getByRole('tree')).toBeTruthy()
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
})
it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => {
const input = props(catalog())
const view = render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
hoverCatalog(trigger)
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
view.unmount()
await Promise.resolve()
})
it('closes every observed catalog when the root becomes empty', () => {
const populated = props(catalog(), {
[CHILD]: catalog({
entries: [{
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
}],
}),
})
const view = render(<SubagentHeaderLineage {...populated} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
const empty = props(catalog({ entries: [] }))
view.rerender(<SubagentHeaderLineage {...empty} />)
expect(screen.queryByRole('button')).toBeNull()
})
it('places a separator before a visible ordinary-session direct-child count', () => {
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
expect(screen.getByText('/')).toBeTruthy()
expect(screen.getByRole('button', { name: /1 个子代理,正在运行/ })).toBeTruthy()
view.rerender(<SubagentHeaderLineage {...props(catalog({ entries: [] }))} />)
expect(screen.queryByText('/')).toBeNull()
})
it('combines the current subagent title and chevron into the parent-catalog trigger', () => {
const sibling = 'child-2' as SessionId
const input = {
...props(catalog(), {}, {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
},
[sibling]: {
...summary(sibling, 1), parentId: PARENT, origin: 'subagent' as const,
},
}),
sessionId: CHILD,
lineageSessionId: CHILD,
displayTitle: 'worker',
}
render(<SubagentHeaderLineage {...input} />)
const switcher = screen.getByRole('button', { name: '切换子代理:worker' })
expect(switcher.className).toContain('switcherTrigger')
expect(within(switcher).getByText('worker')).toBeTruthy()
expect(switcher.querySelector('svg')).not.toBeNull()
const switcherIcon = switcher.querySelector('svg')
expect(switcherIcon?.getAttribute('width')).toBe('16')
expect(switcherIcon?.getAttribute('height')).toBe('16')
hoverCatalog(switcher)
expect(input.refreshProjection).not.toHaveBeenCalled()
const current = screen.getByRole('treeitem', { name: /worker/ })
expect(current.getAttribute('aria-current')).toBe('true')
expect(within(current).getByText('worker').className).toContain('currentLabel')
fireEvent.click(screen.getByRole('treeitem', { name: /reviewer/ }))
expect(input.openChild).toHaveBeenCalledWith({
parentSessionId: PARENT,
childSessionId: sibling, mode: 'one-shot',
})
})
it('does not derive a subagent address from Session summary lineage', () => {
const input = {
...props(undefined, {}, {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
},
}),
lineageSessionId: CHILD,
displayTitle: 'worker',
}
render(<SubagentHeaderLineage {...input} />)
expect(screen.queryByRole('button', { name: '切换子代理:worker' })).toBeNull()
})
it('falls back to the catalog session id when the selected row has no label', () => {
const input = {
...props(catalog({ entries: [{
id: CHILD, mode: 'one-shot',
activity: 'inactive',
}] }), {}, {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
},
}),
sessionId: CHILD,
lineageSessionId: CHILD,
displayTitle: 'summary title',
}
render(<SubagentHeaderLineage {...input} />)
expect(screen.getByRole('button', { name: `切换子代理:${CHILD}` })).toBeTruthy()
})
it('keeps an ancestor switcher muted and omits its descendant count', () => {
const input = {
...props(catalog(), {
[CHILD]: catalog({ entries: [{
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
}] }),
}, {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
},
[GRANDCHILD]: {
...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const,
},
}),
lineageSessionId: CHILD,
displayTitle: '正在扫描项目文件',
openTitle: vi.fn(),
}
render(<SubagentHeaderLineage {...input} />)
const switcher = screen.getByRole('button', { name: '切换子代理:worker' })
expect(switcher.className).toContain('ancestorSwitcherTrigger')
expect(screen.queryByRole('button', { name: /1 个子代理/ })).toBeNull()
vi.useFakeTimers()
fireEvent.mouseEnter(switcher.parentElement!)
fireEvent.click(switcher)
expect(input.openTitle).toHaveBeenCalledOnce()
act(() => { vi.advanceTimersByTime(150) })
expect(screen.queryByRole('tree')).toBeNull()
hoverCatalog(switcher)
expect(screen.getByRole('tree')).toBeTruthy()
fireEvent.click(switcher)
expect(input.openTitle).toHaveBeenCalledTimes(2)
expect(screen.queryByRole('tree')).toBeNull()
})
it.each([
['ancestor', vi.fn()],
['current', undefined],
] as const)('keeps an absent %s switcher catalog lazy until interaction', (_kind, openTitle) => {
const input = {
...props(undefined, {}, {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
displayTitle: '正在扫描项目文件',
},
}, { parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' }),
lineageSessionId: CHILD,
displayTitle: '正在扫描项目文件',
...(openTitle === undefined ? {} : { openTitle }),
}
render(<SubagentHeaderLineage {...input} />)
expect(screen.getByRole('button', { name: '切换子代理:正在扫描项目文件' })).toBeTruthy()
expect(input.refreshProjection).not.toHaveBeenCalled()
})
it('keeps a nested title switcher scoped to its direct-parent catalog', () => {
const input = {
...props(catalog(), {
[CHILD]: catalog({ entries: [{
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
}] }),
}, {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
},
[GRANDCHILD]: {
...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const,
},
}),
sessionId: GRANDCHILD,
lineageSessionId: GRANDCHILD,
displayTitle: 'indexer',
}
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: '切换子代理:indexer' }))
expect(input.refreshProjection).not.toHaveBeenCalled()
const current = screen.getByRole('treeitem', { name: /indexer/ })
expect(current.getAttribute('aria-current')).toBe('true')
expect(within(current).getByText('indexer').className).toContain('currentLabel')
expect(screen.queryByRole('treeitem', { name: /reviewer/ })).toBeNull()
})
})
describe('SubagentReadOnlyComposer', () => {
it('explains the exact missing-parent recovery path', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
})
it('keeps an unknown child read-only until its descriptor is available', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'unknown' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('读取子会话后才能确定是否可继续')
})
it('explains that one-shot histories never accept follow-ups', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息')
})
})