Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* TrajectoryCell presentation: kind tags, ellipsis-hosting text, Message
|
||||
* metric columns, own-duration formatting, and selected ring.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
formatElapsedSeconds,
|
||||
TrajectoryCell,
|
||||
type TrajectoryCellKind,
|
||||
} from '../src/client/TrajectoryCell.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('formatElapsedSeconds', () => {
|
||||
it('formats known durations and uses an em dash when absent', () => {
|
||||
expect(formatElapsedSeconds(null)).toBe('—')
|
||||
expect(formatElapsedSeconds(235)).toBe('+235s')
|
||||
expect(formatElapsedSeconds(235.0)).toBe('+235s')
|
||||
expect(formatElapsedSeconds(235.2)).toBe('+235.2s')
|
||||
expect(formatElapsedSeconds(235.25)).toBe('+235.3s')
|
||||
expect(formatElapsedSeconds(0)).toBe('+0s')
|
||||
expect(formatElapsedSeconds(Number.NaN)).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryCell', () => {
|
||||
it('renders index, kind tag, text, and time for a Tool row', () => {
|
||||
render(
|
||||
<TrajectoryCell
|
||||
index={6}
|
||||
kind="tool"
|
||||
text="bash · Read src/index.ts"
|
||||
timeSeconds={5}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('#6')).toBeTruthy()
|
||||
expect(screen.getByText('Tool')).toBeTruthy()
|
||||
expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy()
|
||||
expect(screen.getByText('+5s')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Message rows expose Input / Output / Think metric columns before time', () => {
|
||||
const { container } = render(
|
||||
<TrajectoryCell
|
||||
index={3}
|
||||
kind="message"
|
||||
text="Let me now read the actual source files to understa..."
|
||||
timeSeconds={235.2}
|
||||
input={136}
|
||||
output={381}
|
||||
think={155}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Message')).toBeTruthy()
|
||||
expect(screen.getByText('136')).toBeTruthy()
|
||||
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)
|
||||
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'))
|
||||
})
|
||||
|
||||
it('selected marks the row for the brand-primary inset ring', () => {
|
||||
const { container } = render(
|
||||
<TrajectoryCell index={15} kind="message" text="pictur..." timeSeconds={123.6} selected />,
|
||||
)
|
||||
expect(container.firstElementChild?.getAttribute('data-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['user', 'User'],
|
||||
['tool', 'Tool'],
|
||||
] as const)('kind %s shows the %s tag and no metric columns', (kind: TrajectoryCellKind, label: string) => {
|
||||
const { container } = render(
|
||||
<TrajectoryCell index={1} kind={kind} text="summary" timeSeconds={kind === 'user' ? 0 : null} input={1} output={2} think={3} />,
|
||||
)
|
||||
expect(screen.getByText(label)).toBeTruthy()
|
||||
expect(container.querySelector('[data-kind]')?.getAttribute('data-kind')).toBe(kind)
|
||||
expect(screen.queryByText('1')).toBeNull()
|
||||
expect(screen.queryByText('2')).toBeNull()
|
||||
expect(screen.queryByText('3')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Trajectory turn chrome and layout fold: expand blocks, usage on Message,
|
||||
* tool own-duration, group wall-span descriptions, in-flight rows.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
|
||||
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
|
||||
import { deriveTrajectoryLayout } from '../src/client/layout.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('TrajectoryTurnHeader', () => {
|
||||
it('renders Turn N and the four metric column labels', () => {
|
||||
render(<TrajectoryTurnHeader turn={1} />)
|
||||
expect(screen.getByText('Turn 1')).toBeTruthy()
|
||||
expect(screen.getByText('Input')).toBeTruthy()
|
||||
expect(screen.getByText('Output')).toBeTruthy()
|
||||
expect(screen.getByText('Think')).toBeTruthy()
|
||||
expect(screen.getByText('Time')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryGroupHeader', () => {
|
||||
it('renders title and optional description', () => {
|
||||
render(<TrajectoryGroupHeader title="Step 1" description="2.2s skill" />)
|
||||
expect(screen.getByText('Step 1')).toBeTruthy()
|
||||
expect(screen.getByText('2.2s skill')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits the description node when absent', () => {
|
||||
const { container } = render(<TrajectoryGroupHeader title="Message" />)
|
||||
expect(screen.getByText('Message')).toBeTruthy()
|
||||
expect(container.querySelectorAll('span')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryTurn', () => {
|
||||
it('wraps a sticky header and body children', () => {
|
||||
render(
|
||||
<TrajectoryTurn turn={3}>
|
||||
<TrajectoryGroupHeader title="Message" description="49s" />
|
||||
</TrajectoryTurn>,
|
||||
)
|
||||
expect(screen.getByText('Turn 3')).toBeTruthy()
|
||||
expect(screen.getByText('Message')).toBeTruthy()
|
||||
expect(screen.getByText('49s')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveTrajectoryLayout', () => {
|
||||
it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'reasoning', text: 'thinking…' },
|
||||
{ kind: 'text', text: 'I will run bash' },
|
||||
{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' },
|
||||
],
|
||||
usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 },
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200,
|
||||
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ 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))
|
||||
expect(kinds).toEqual(['user', 'message', 'tool'])
|
||||
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')
|
||||
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'],
|
||||
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[0]?.cells[0]).toMatchObject({
|
||||
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits duration when node times are missing instead of rendering NaN', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'reasoning', text: '…' },
|
||||
{ kind: 'text', text: 'ok' },
|
||||
],
|
||||
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()
|
||||
})
|
||||
|
||||
it('builds a wall-span step description with a tool histogram', () => {
|
||||
const nodes = [
|
||||
{
|
||||
kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' },
|
||||
{ kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 2, time: 2_500, callId: 'a',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 4_000, callId: 'b',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
|
||||
})
|
||||
|
||||
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'text', text: 'ok1' }],
|
||||
},
|
||||
{ kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0,
|
||||
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'])
|
||||
})
|
||||
|
||||
it('keeps usage on the fallback Message row when assistant has no text block', () => {
|
||||
const nodes = [
|
||||
{
|
||||
kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'reasoning', text: '…' }],
|
||||
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')
|
||||
expect(message).toMatchObject({
|
||||
text: '', input: 11, output: 22, think: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('advances the duration cursor over context nodes', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }],
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{
|
||||
kind: 'context', seq: 4, time: 9_000,
|
||||
content: [{ type: 'text', text: 'extra' }], source: null,
|
||||
},
|
||||
{
|
||||
kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'text', text: 'done' }],
|
||||
},
|
||||
] 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' && c.text === 'done')
|
||||
// From context at 9s, not from the earlier user/tool surfaces.
|
||||
expect(message?.timeSeconds).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -3,9 +3,9 @@
|
||||
* View registration acceptance on the real framework stack: the plugin fiber
|
||||
* registers trajectory/waterfall into a real SlotsService view ring, tabs
|
||||
* switch inside ConversationRoot (renderSlot share driven by the same tab
|
||||
* projection apply uses) without collapsing chat, the span stats header
|
||||
* renders inside both view bodies, and fiber disposal removes both tabs.
|
||||
* Span derivation edge cases ride along.
|
||||
* projection apply uses) without collapsing chat, trajectory renders the
|
||||
* turn-list chrome (no span stats bar), waterfall keeps in-body stats, and
|
||||
* fiber disposal removes both tabs. Span derivation edge cases ride along.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -36,19 +36,26 @@ afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear so one case's active
|
||||
// view cannot rehydrate into the next.
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// Node 22+ exposes an experimental localStorage global that is undefined
|
||||
// without --localstorage-file; only clear when a real Storage is present.
|
||||
if (typeof localStorage !== 'undefined') localStorage.clear()
|
||||
})
|
||||
|
||||
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
|
||||
const NODES = [
|
||||
{ kind: 'user', seq: 1, content: [], source: null },
|
||||
{ kind: 'assistant', seq: 2, turn: 1, step: 1, blocks: [] },
|
||||
{ kind: 'tool-result', seq: 3, callId: 'c1', call: null, content: [], isError: false, callView: null, resultView: null },
|
||||
{ kind: 'assistant', seq: 4, turn: 2, step: 1, blocks: [] },
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
|
||||
{ kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] },
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{ kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] },
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes })
|
||||
const store = createSnapshotStore({
|
||||
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
|
||||
@@ -99,8 +106,9 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
|
||||
/** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
|
||||
const sessionSnapshot = createSnapshotStore({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
const chat = createChatStore().create()
|
||||
@@ -162,17 +170,20 @@ describe('plugin registration', () => {
|
||||
})
|
||||
|
||||
describe('tab switching in ConversationRoot', () => {
|
||||
it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => {
|
||||
it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => {
|
||||
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'])
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
// In-body header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call.
|
||||
expect(screen.getByText('3 turns · 2 steps · 1 tool calls')).toBeTruthy()
|
||||
expect(screen.getByText('turn 0')).toBeTruthy()
|
||||
expect(screen.getByText('1 steps · 1 calls · 2 nodes')).toBeTruthy()
|
||||
// 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()
|
||||
expect(screen.getAllByText('Message').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Input').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByTestId('chat-body')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user