Merge origin/master into web-permission-sandbox
39 conflicts resolved per the reattachment plan (missions worktree-projbiz 0728-1859): baseline wins for deleted packages (host/runtime, old ui/acp, ui-sidebar Rows/tree) and retired specs; unions for wire-layer exports and client summary fields; the approval takeover, waitingApprovals tracking, and PendingApproval domain face carry over onto the master structure. The two new host specs follow the runtime->apiproxy rename. Dead PR-side wiring (ConversationInjected permissions/setPermission spread, InputBar controls prop, boot.ts sandbox composition) resolves to master and its replacement lands in follow-up commits.
This commit is contained in:
@@ -58,7 +58,7 @@ describe('TrajectoryCell', () => {
|
||||
expect(screen.getByText('381')).toBeTruthy()
|
||||
expect(screen.getByText('155')).toBeTruthy()
|
||||
expect(screen.getByText('+235.2s')).toBeTruthy()
|
||||
const texts = [...container.querySelectorAll('span')].map((el) => el.textContent)
|
||||
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
|
||||
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
|
||||
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
|
||||
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))
|
||||
|
||||
@@ -70,30 +70,31 @@ describe('deriveTrajectoryLayout', () => {
|
||||
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
expect(turns).toHaveLength(1)
|
||||
expect(turns[0]?.turn).toBe(1)
|
||||
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
|
||||
const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
|
||||
expect(kinds).toEqual(['user', 'message', 'tool'])
|
||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
||||
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
input: 10, output: 20, think: 5, timeSeconds: 5,
|
||||
})
|
||||
const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool')
|
||||
const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool')
|
||||
expect(tool?.text).toBe('bash · {"command":"ls"}')
|
||||
expect(tool?.timeSeconds).toBe(1.3)
|
||||
})
|
||||
|
||||
it('adds runningCalls not already present and leaves their time blank', () => {
|
||||
const turns = deriveTrajectoryLayout({
|
||||
nodes: [] as unknown as ConversationSnapshot['nodes'],
|
||||
codeDispatches: new Map(),
|
||||
nodes: [],
|
||||
partial: null,
|
||||
runningCalls: [{
|
||||
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
|
||||
turn: 1, step: 2, time: 9_000, callView: null,
|
||||
}],
|
||||
})
|
||||
expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2'])
|
||||
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
|
||||
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
|
||||
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
|
||||
})
|
||||
@@ -111,10 +112,10 @@ describe('deriveTrajectoryLayout', () => {
|
||||
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
|
||||
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
|
||||
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
|
||||
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
|
||||
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds a wall-span step description with a tool histogram', () => {
|
||||
@@ -137,7 +138,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
|
||||
})
|
||||
|
||||
@@ -154,10 +155,10 @@ describe('deriveTrajectoryLayout', () => {
|
||||
blocks: [{ kind: 'text', text: 'ok2' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
expect(turns.map((t) => t.turn)).toEqual([1, 2])
|
||||
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
|
||||
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
expect(turns.map(t => t.turn)).toEqual([1, 2])
|
||||
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
|
||||
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
|
||||
})
|
||||
|
||||
it('keeps usage on the fallback Message row when assistant has no text block', () => {
|
||||
@@ -168,8 +169,8 @@ describe('deriveTrajectoryLayout', () => {
|
||||
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
text: '', input: 11, output: 22, think: 3,
|
||||
})
|
||||
@@ -196,11 +197,59 @@ describe('deriveTrajectoryLayout', () => {
|
||||
blocks: [{ kind: 'text', text: 'done' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups
|
||||
.flatMap((g) => g.cells)
|
||||
.find((c) => c.kind === 'message' && c.text === 'done')
|
||||
.flatMap(g => g.cells)
|
||||
.find(c => c.kind === 'message' && c.text === 'done')
|
||||
// From context at 9s, not from the earlier user/tool surfaces.
|
||||
expect(message?.timeSeconds).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('run_code sub-dispatch cells', () => {
|
||||
const runCodeNodes = [
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'tool-call', callId: 'p1', name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
|
||||
call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
|
||||
content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
const settledSub = (n: number, name: string, start: number, end: number) => ({
|
||||
kind: 'tool-result' as const, seq: 100 + n, time: end,
|
||||
callId: `p1:code:${n}`,
|
||||
call: { name, argsRaw: '{"x":1}' }, callTime: start,
|
||||
content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
it('nests settled sub-cells after their parent Tool cell with real durations', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
settledSub(1, 'bash', 6_300, 7_300),
|
||||
settledSub(2, 'read', 7_300, 7_800),
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]!.groups.flatMap(g => g.cells)
|
||||
expect(cells.map(c => c.kind)).toEqual(['tool', 'subtool', 'subtool'])
|
||||
// Sequential indexes across the interleave; durations from the pair times.
|
||||
expect(cells.map(c => c.index)).toEqual([1, 2, 3])
|
||||
expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 })
|
||||
expect(cells[2]).toMatchObject({ timeSeconds: 0.5 })
|
||||
})
|
||||
|
||||
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
|
||||
const running = {
|
||||
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
|
||||
turn: 0, step: 0, time: 6_400, callView: null,
|
||||
}
|
||||
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
|
||||
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
|
||||
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,23 +15,19 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
|
||||
import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
|
||||
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
|
||||
import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
|
||||
import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
/** Fallback-only chain stub (no composer takeover in these benches). */
|
||||
const fallbackRenderSlotChain: ConversationRootProps['renderSlotChain'] =
|
||||
(_key, _owner, opts) => opts?.fallback ?? null
|
||||
|
||||
afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear so one case's active
|
||||
// view cannot rehydrate into the next.
|
||||
@@ -54,20 +50,25 @@ const NODES = [
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore({
|
||||
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
|
||||
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id; engines carry no hook since the store migration — bind here). */
|
||||
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: undefined,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
/** Standalone view props: the session-scope standard kit the outlet would bake. */
|
||||
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
@@ -75,6 +76,8 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
sessionId: SID,
|
||||
useSession: fakeSession(nodes).useSession,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined) as never,
|
||||
} as unknown as ConvViewProps
|
||||
}
|
||||
|
||||
@@ -104,11 +107,11 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
.map(e => ({ id: e.options.id!, label: e.options.label ?? e.options.id! }))
|
||||
}
|
||||
|
||||
/** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */
|
||||
/** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
const chat = createChatStore().create()
|
||||
@@ -121,28 +124,30 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
const View = entry.component as FC<ConvViewProps>
|
||||
return (
|
||||
<View
|
||||
{...({ sessionId: SID, useSession, useSessions: emptySessions() } as unknown as ConvViewProps)}
|
||||
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
|
||||
key={key}
|
||||
/>
|
||||
)
|
||||
}) as unknown as ConversationRootProps['renderSlot']
|
||||
}) as unknown as ConversationSessionProps['renderSlot']
|
||||
return render(
|
||||
<ConversationRoot
|
||||
<ConversationSession
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={emptySessions()}
|
||||
useWorkspaces={emptyWorkspaces()}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot}
|
||||
renderSlotChain={fallbackRenderSlotChain}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{
|
||||
list: () => tabsOf(slots),
|
||||
subscribe: (fn) => slots.subscribe('conversation.view', fn),
|
||||
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
}}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
|
||||
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
|
||||
bindDraftMirror={() => () => {}}
|
||||
open={vi.fn()}
|
||||
permissions={() => Promise.resolve(null)}
|
||||
setPermission={() => Promise.resolve(null)}
|
||||
@@ -163,7 +168,7 @@ describe('plugin registration', () => {
|
||||
it('fiber disposal removes both tabs and leaves chat standing', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.dispose()
|
||||
expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat'])
|
||||
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,10 +177,9 @@ describe('tab switching in ConversationRoot', () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||
expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
// Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body.
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
expect(screen.getByText('Turn 1')).toBeTruthy()
|
||||
expect(screen.getByText('Turn 2')).toBeTruthy()
|
||||
@@ -198,7 +202,7 @@ describe('tab switching in ConversationRoot', () => {
|
||||
|
||||
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
|
||||
mount(b.slots, [])
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
@@ -221,12 +225,12 @@ describe('span derivation', () => {
|
||||
})
|
||||
|
||||
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
|
||||
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
||||
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
|
||||
expect(deriveSpanStats(deriveSpans([]))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
||||
const { useSession } = fakeSession([])
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession }))
|
||||
expect(container.firstChild).toBeNull()
|
||||
render(createElement(TrajectoryView as FC<ConvViewProps>,
|
||||
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
||||
standaloneProps([])))
|
||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -234,7 +238,7 @@ describe('span derivation', () => {
|
||||
describe('WaterfallView standalone branches', () => {
|
||||
it('empty window renders the placeholder copy', () => {
|
||||
render(createElement(WaterfallView as FC<ConvViewProps>,
|
||||
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
||||
standaloneProps([])))
|
||||
expect(screen.getByText('暂无瀑布数据')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -248,6 +252,120 @@ describe('WaterfallView standalone branches', () => {
|
||||
|
||||
describe('node half', () => {
|
||||
it('node apply is an intentional no-op (loader-managed lifecycle only)', () => {
|
||||
expect(nodeApply()).toBeUndefined()
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSubSpans (waterfall lanes)', () => {
|
||||
const dispatchNodes = [
|
||||
{ kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] },
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
|
||||
call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
it('scales settled lanes into the dispatch window with real durations', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2',
|
||||
call: { name: 'read', argsRaw: '{}' }, callTime: 7_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
||||
const turn3 = lanes.get(3)
|
||||
expect(turn3).toHaveLength(2)
|
||||
// Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0.
|
||||
expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 })
|
||||
expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4)
|
||||
expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 })
|
||||
expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4)
|
||||
})
|
||||
|
||||
it('a running lane extends to the window end with a null duration', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{ callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null },
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lanes = deriveSubSpans(dispatchNodes, codeDispatches)
|
||||
const running = lanes.get(3)?.find(lane => lane.name === 'grep')
|
||||
expect(running).toMatchObject({ durationMs: null, timing: 'running' })
|
||||
// Extends from its start to the window end.
|
||||
expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: null,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0]
|
||||
expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' })
|
||||
})
|
||||
|
||||
it('waterfall renders sub-span lanes under the owning turn row', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const store = createSnapshotStore({
|
||||
nodes: dispatchNodes, partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
|
||||
})
|
||||
const props = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined) as never,
|
||||
} as unknown as ConvViewProps
|
||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||
const lane = view.container.querySelector('[data-subspan]')
|
||||
expect(lane).not.toBeNull()
|
||||
expect(lane!.textContent).toContain('bash')
|
||||
expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull()
|
||||
expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('waterfall labels a settle-only lane as duration unknown', () => {
|
||||
const codeDispatches = new Map([['p1', [
|
||||
{
|
||||
kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1',
|
||||
call: { name: 'read', argsRaw: '{}' }, callTime: null,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
]]]) as unknown as ConversationSnapshot['codeDispatches']
|
||||
const store = createSnapshotStore({
|
||||
nodes: dispatchNodes, partial: null,
|
||||
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches,
|
||||
})
|
||||
const props = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot>,
|
||||
useSessions: emptySessions(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useProjection: (() => undefined) as never,
|
||||
} as unknown as ConvViewProps
|
||||
const view = render(createElement(WaterfallView as FC<ConvViewProps>, props))
|
||||
const bar = view.container.querySelector('[data-timing="unknown"]')
|
||||
expect(bar).not.toBeNull()
|
||||
expect(bar!.getAttribute('title')).toContain('duration unknown')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user