1260 lines
54 KiB
TypeScript
1260 lines
54 KiB
TypeScript
import { Context } from '@deepseek-ai/cordis'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { AttachmentId, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
|
|
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import type {
|
|
AssistantLiveChunkEvent, SessionEventLike, SessionEventLikeEntry,
|
|
SessionLiveEventEntry, SessionTransientEventEntry,
|
|
} from '@deepseek-ai/dsh-api-session-controller/client'
|
|
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
|
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
|
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import type {
|
|
ConversationContextReader, ConversationLocation, ConversationMatch,
|
|
ConversationNodeContext, ConversationNodeDefinition, ConversationStartMatch,
|
|
ConversationViewDefinition,
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
|
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
|
|
import { de, en, es, fr, ja, NS, pt, ru, zh } from '../src/client/locales.ts'
|
|
import type {
|
|
StatsCall, StatsContribution, StatsSnapshot, StatsStepMetrics, StatsStepState, StatsViewNode,
|
|
} from '../src/client/stats-contract.ts'
|
|
import { TARGET } from '../src/client/stats-definition.ts'
|
|
import { eventMetrics } from '../src/client/stats-event.ts'
|
|
import { StatsView } from '../src/client/StatsView.tsx'
|
|
import {
|
|
EMPTY_STATS_SNAPSHOT, statsViewDefinition, registerStatsConversationView,
|
|
} from '../src/client/stats-snapshot-builder.ts'
|
|
import { apply as clientApply, inject as clientInject } from '../src/client/index.ts'
|
|
import type { StatsViewInjected } from '../src/client/StatsView.tsx'
|
|
import { apply as hostApply } from '../src/index.ts'
|
|
|
|
const SID = 'stats-session' as SessionId
|
|
|
|
/** One durable image the prompt fixtures attach. */
|
|
const IMAGE: ImageAttachmentRef = {
|
|
attachmentId: AttachmentId('sha256:image'),
|
|
mediaType: 'image/png',
|
|
bytes: 12,
|
|
width: 2,
|
|
height: 2,
|
|
}
|
|
|
|
let seq = 0
|
|
|
|
/**
|
|
* One durable session event with a deterministic sequence and time. Payloads
|
|
* are fixtures, so this is where the test stops type-checking them.
|
|
*/
|
|
function event(type: string, data: unknown, time?: number): SessionEvent {
|
|
seq += 1
|
|
return { type, seq: SessionSeq(seq), time: time ?? 1_000 + seq, data } as SessionEvent
|
|
}
|
|
|
|
/** One event with an explicit surface operation. */
|
|
function surfaceEvent(type: string, data: unknown, surfaceOp: string, time?: number): SessionEvent {
|
|
return { ...event(type, data, time), surfaceOp } as SessionEvent
|
|
}
|
|
|
|
/** One durable window entry. */
|
|
function entry(event: SessionEvent): SessionLiveEventEntry {
|
|
return { type: 'event', event }
|
|
}
|
|
|
|
/** One client-only live chunk entry. */
|
|
function transient(turn: number, step: number, chunk: StreamChunk, time?: number): SessionTransientEventEntry {
|
|
seq += 1
|
|
const chunkEvent: AssistantLiveChunkEvent = {
|
|
type: 'assistant/live-chunk',
|
|
seq,
|
|
time: time ?? 1_000 + seq,
|
|
data: { attemptId: LlmAttemptId('attempt-1'), turn, step, chunk },
|
|
}
|
|
return { type: 'transient', event: chunkEvent }
|
|
}
|
|
|
|
/** The text delta one live chunk reports. */
|
|
const delta: StreamChunk = { type: 'text-delta', index: 0, text: 'x' }
|
|
|
|
/** Usage reported by one assistant message. */
|
|
const usage = { inputTokens: 100, outputTokens: 40, cacheReadTokens: 400, cacheWriteTokens: 100 }
|
|
|
|
/** A compact assistant stream whose first token arrives `at` milliseconds. */
|
|
function stream(at: number): readonly { readonly type: 'chunk'; readonly time: number; readonly chunk: StreamChunk }[] {
|
|
return [
|
|
{ type: 'chunk', time: at - 100, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
|
|
{ type: 'chunk', time: at, chunk: delta },
|
|
]
|
|
}
|
|
|
|
/** A definition registry over the Definitions captured from registration. */
|
|
class TestEventDefinitions {
|
|
constructor(readonly definitions: readonly ConversationNodeDefinition[]) {}
|
|
|
|
entries(): readonly ConversationNodeDefinition[] {
|
|
return this.definitions
|
|
}
|
|
|
|
fallbackEntry(): ConversationNodeDefinition | undefined {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** A view registry over the builders captured from registration. */
|
|
class TestViewDefinitions {
|
|
constructor(readonly definitions: readonly ConversationViewDefinition[]) {}
|
|
|
|
entries(): readonly ConversationViewDefinition[] {
|
|
return this.definitions
|
|
}
|
|
}
|
|
|
|
/** Assembler that activates every registered target, like the Conversation store. */
|
|
class StatsAssembler extends ConversationNodeAssembler {
|
|
constructor(events: TestEventDefinitions, views: TestViewDefinitions) {
|
|
super(events, views)
|
|
for (const view of views.entries()) this.activateTarget(view.target)
|
|
}
|
|
}
|
|
|
|
/** Recorded browser-plugin surface: locale, Definitions, views, and the injected source. */
|
|
interface PluginBench {
|
|
readonly ctx: Context
|
|
readonly fiber: { dispose(): Promise<void> }
|
|
readonly localeDispose: ReturnType<typeof vi.fn>
|
|
readonly events: readonly ConversationNodeDefinition[]
|
|
readonly views: readonly ConversationViewDefinition[]
|
|
readonly localeRegister: ReturnType<typeof vi.fn>
|
|
readonly binding: ReturnType<typeof vi.fn>
|
|
readonly loadOlder: ReturnType<typeof vi.fn>
|
|
readonly imageUrl: ReturnType<typeof vi.fn>
|
|
readonly setOpenState: (state: 'cold' | 'open' | 'error') => void
|
|
readonly settleOnNextSubscribe: () => void
|
|
readonly setHasMore: (value: boolean) => void
|
|
readonly setLoadingOlder: (value: boolean) => void
|
|
readonly entries: () => readonly StoredEntry[]
|
|
readonly setSnapshot: (next: StatsSnapshot | undefined) => void
|
|
readonly notify: () => void
|
|
}
|
|
|
|
/** Mount the browser plugin on a real Cordis context with stub services. */
|
|
async function mountPlugin(): Promise<PluginBench> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SlotRegistry).await()
|
|
const events: ConversationNodeDefinition[] = []
|
|
const views: ConversationViewDefinition[] = []
|
|
let snapshot: StatsSnapshot | undefined
|
|
let listener: (() => void) | undefined
|
|
const localeDispose = vi.fn()
|
|
const localeRegister = vi.fn(() => localeDispose)
|
|
const binding = vi.fn(() => ({
|
|
target: () => ({
|
|
getSnapshot: () => snapshot,
|
|
subscribe: (next: () => void) => {
|
|
listener = next
|
|
return () => { listener = undefined }
|
|
},
|
|
}),
|
|
}))
|
|
const loadOlder = vi.fn(async () => undefined)
|
|
const imageUrl = vi.fn(async () => 'blob:image')
|
|
let openState: 'cold' | 'open' | 'error' = 'open'
|
|
let hasMore = false
|
|
let loadingOlder = false
|
|
let openListener: (() => void) | undefined
|
|
let settleOnSubscribe = false
|
|
const sessionBinding = {
|
|
session: {
|
|
loadOlder,
|
|
getSnapshot: () => ({ openState, hasMore, loadingOlder }),
|
|
subscribe: (next: () => void) => {
|
|
openListener = next
|
|
if (settleOnSubscribe) {
|
|
settleOnSubscribe = false
|
|
openState = 'open'
|
|
}
|
|
return () => { openListener = undefined }
|
|
},
|
|
},
|
|
}
|
|
ctx.provide('sessions', { binding: (id: SessionId) => (id === SID ? sessionBinding : undefined) } as never)
|
|
ctx.provide('locale', { register: localeRegister, bind: () => makeTranslate(zh) } as never)
|
|
ctx.provide('uiConversation', {
|
|
binding,
|
|
imageUrl,
|
|
events: {
|
|
register: (definition: ConversationNodeDefinition) => {
|
|
events.push(definition)
|
|
return () => undefined
|
|
},
|
|
},
|
|
views: {
|
|
register: (definition: ConversationViewDefinition) => {
|
|
views.push(definition)
|
|
return () => undefined
|
|
},
|
|
},
|
|
} as never)
|
|
ctx.slots.register({
|
|
name: 'root',
|
|
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
|
} as never, () => null)
|
|
const fiber = ctx.plugin({ inject: [...clientInject], apply: clientApply })
|
|
await fiber.await()
|
|
return {
|
|
ctx,
|
|
fiber,
|
|
localeDispose,
|
|
events,
|
|
views,
|
|
localeRegister,
|
|
binding,
|
|
loadOlder,
|
|
imageUrl,
|
|
setOpenState: (state: 'cold' | 'open' | 'error') => {
|
|
openState = state
|
|
openListener?.()
|
|
},
|
|
settleOnNextSubscribe: () => { settleOnSubscribe = true },
|
|
setHasMore: (value: boolean) => { hasMore = value },
|
|
setLoadingOlder: (value: boolean) => { loadingOlder = value },
|
|
entries: () => ctx.slots.entries('conversation.view'),
|
|
setSnapshot: (next) => { snapshot = next },
|
|
notify: () => { listener?.() },
|
|
}
|
|
}
|
|
|
|
/** Run one assembler window and return the published stats snapshot. */
|
|
function assemble(
|
|
definitions: readonly ConversationNodeDefinition[],
|
|
entries: readonly SessionEventLikeEntry[],
|
|
options: { readonly upserts?: readonly SessionEventLikeEntry[] } = {},
|
|
): StatsSnapshot | undefined {
|
|
const assembler = new StatsAssembler(
|
|
new TestEventDefinitions(definitions),
|
|
new TestViewDefinitions([statsViewDefinition]),
|
|
)
|
|
assembler.replaceWindow(entries, false)
|
|
assembler.flush()
|
|
for (const record of options.upserts ?? []) {
|
|
assembler.append(record)
|
|
assembler.flush()
|
|
}
|
|
return assembler.get(TARGET)
|
|
}
|
|
|
|
/** The Definitions captured by one plugin registration. */
|
|
async function registeredDefinitions(): Promise<readonly ConversationNodeDefinition[]> {
|
|
const bench = await mountPlugin()
|
|
const definitions = bench.events
|
|
await bench.fiber.dispose()
|
|
return definitions
|
|
}
|
|
|
|
/** One measured step metrics value. */
|
|
function measuredStep(turn: number, step: number, startTime: number, endTime: number): StatsStepMetrics {
|
|
const call: StatsCall = {
|
|
callId: 'call-1',
|
|
seq: 2,
|
|
name: 'read',
|
|
hint: 'b/c.ts',
|
|
chars: 7,
|
|
lines: 2,
|
|
error: false,
|
|
added: null,
|
|
removed: null,
|
|
before: null,
|
|
after: null,
|
|
args: '{"file_path":"/a/b/c.ts"}',
|
|
inner: false,
|
|
text: 'one\ntwo',
|
|
truncated: false,
|
|
ms: 200,
|
|
startedAt: startTime + 50,
|
|
endedAt: endTime - 10,
|
|
}
|
|
return {
|
|
turn,
|
|
step,
|
|
startTime,
|
|
endTime,
|
|
context: 600,
|
|
ttft: 100,
|
|
model: 400,
|
|
decode: 300,
|
|
tps: 40 / 0.3,
|
|
output: 40,
|
|
tool: 200,
|
|
toolWall: 200,
|
|
tools: [{ name: 'read', count: 1 }],
|
|
calls: [call],
|
|
toolOutput: 7,
|
|
retries: 0,
|
|
toolErrors: 0,
|
|
answer: null,
|
|
answerBody: null,
|
|
answerTruncated: false,
|
|
cacheRead: 400,
|
|
cacheWrite: 100,
|
|
cacheHit: 400 / 600,
|
|
wall: endTime - startTime,
|
|
toolPending: 0,
|
|
running: false,
|
|
interrupted: false,
|
|
aborted: false,
|
|
}
|
|
}
|
|
|
|
/** One view node carrying the given contribution. */
|
|
function node(key: string, data: StatsContribution): StatsViewNode {
|
|
return {
|
|
key,
|
|
kind: key.split(':')[0] ?? key,
|
|
id: key,
|
|
target: TARGET,
|
|
anchorSeq: 0,
|
|
location: { kind: 'unresolved' },
|
|
data,
|
|
}
|
|
}
|
|
|
|
/** A start-role match for one event. */
|
|
function startMatch(event: SessionEventLike): ConversationStartMatch {
|
|
return { event, role: 'start', location: { kind: 'unresolved' } }
|
|
}
|
|
|
|
/** An update-role match for one event. */
|
|
function updateMatch(event: SessionEventLike): ConversationMatch {
|
|
return { event, role: 'update', location: { kind: 'unresolved' } }
|
|
}
|
|
|
|
/** The reader a start callback receives; the stats Definitions read no predecessors. */
|
|
const reader: ConversationContextReader = { previous: () => undefined }
|
|
|
|
/** One hand-built Definition context. */
|
|
function contextOf(
|
|
definition: ConversationNodeDefinition<StatsStepState>,
|
|
state: StatsStepState | undefined,
|
|
matches: readonly ConversationMatch[] = [],
|
|
start?: ConversationStartMatch,
|
|
): ConversationNodeContext<StatsStepState> {
|
|
return {
|
|
key: `${definition.kind}:fixture`,
|
|
kind: definition.kind,
|
|
id: 'fixture',
|
|
matches,
|
|
start,
|
|
state,
|
|
current: new Map(),
|
|
}
|
|
}
|
|
|
|
/** Location data store the hand-built Locations never let a Definition read. */
|
|
const LOCATION_DATA = {
|
|
get: () => undefined,
|
|
source: () => ({ getSnapshot: () => undefined, subscribe: () => () => undefined }),
|
|
} as never
|
|
|
|
/** Fabricated Turn Location carrying only the number a Definition reads. */
|
|
function turnAt(turn: number): ConversationLocation {
|
|
return {
|
|
kind: 'turn',
|
|
turn: { turn, start: undefined, end: undefined, status: 'open', steps: [], data: LOCATION_DATA },
|
|
}
|
|
}
|
|
|
|
/** Fabricated Step Location carrying only the numbers a Definition reads. */
|
|
function stepAt(turn: number, step: number): ConversationLocation {
|
|
return {
|
|
kind: 'step',
|
|
turn: { turn, start: undefined, end: undefined, status: 'open', steps: [], data: LOCATION_DATA },
|
|
step: { turn, step, start: undefined, end: undefined, status: 'unknown', data: LOCATION_DATA },
|
|
}
|
|
}
|
|
|
|
/** A start-role match placed at a fabricated Location. */
|
|
function locatedStart(event: SessionEventLike, location: ConversationLocation): ConversationStartMatch {
|
|
return { event, role: 'start', location }
|
|
}
|
|
|
|
/** An update-role match placed at a fabricated Location. */
|
|
function locatedUpdate(event: SessionEventLike, location: ConversationLocation): ConversationMatch {
|
|
return { event, role: 'update', location }
|
|
}
|
|
|
|
describe('browser plugin registration', () => {
|
|
it('registers the performance tab, dictionaries, Definitions, and view target', async () => {
|
|
const bench = await mountPlugin()
|
|
expect(bench.localeRegister).toHaveBeenCalledWith(NS, { zh, en, ja, de, es, fr, pt, ru })
|
|
expect(bench.events.map(definition => definition.kind)).toEqual([
|
|
'stats-step', 'stats-prompt', 'stats-turn-end', 'stats-code-mode', 'stats-compaction', 'stats-event',
|
|
])
|
|
expect(clientInject).toEqual(['slots', 'sessions', 'uiConversation', 'locale'])
|
|
expect(bench.views).toEqual([statsViewDefinition])
|
|
expect(bench.entries()).toHaveLength(1)
|
|
const registered = bench.entries()[0]!
|
|
expect(registered.component).toBe(StatsView)
|
|
expect(registered.options).toMatchObject({ id: 'stats', order: 40 })
|
|
expect(registered.locale).toBe(NS)
|
|
expect(resolveSlotLabel(registered.options.label)).toBe('统计')
|
|
await bench.fiber.dispose()
|
|
expect(bench.entries()).toEqual([])
|
|
// The dictionary registration rides the plugin fiber effect; Definitions and
|
|
// view targets dispose through their own registries in the wiring suites.
|
|
expect(bench.localeDispose).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('settles when the subscription resolves while it is being set up', async () => {
|
|
const bench = await mountPlugin()
|
|
const injectEntry = bench.entries()[0]!.inject as ((sessionId: SessionId) => StatsViewInjected) | undefined
|
|
const injected = injectEntry!(SID)
|
|
bench.setOpenState('cold')
|
|
bench.settleOnNextSubscribe()
|
|
expect(await injected.loadOlder()).toBe('exhausted')
|
|
expect(bench.loadOlder).toHaveBeenCalledTimes(1)
|
|
// A no-progress page with older history left is surfaced, not hidden as exhausted.
|
|
bench.setHasMore(true)
|
|
expect(await injected.loadOlder()).toBe('retry')
|
|
await bench.fiber.dispose()
|
|
})
|
|
|
|
it('waits for a cold Session to open before asking for older history', async () => {
|
|
const bench = await mountPlugin()
|
|
const injectEntry = bench.entries()[0]!.inject as ((sessionId: SessionId) => StatsViewInjected) | undefined
|
|
const injected = injectEntry!(SID)
|
|
bench.setOpenState('cold')
|
|
let settled = false
|
|
const page = injected.loadOlder().then((value) => { settled = true; return value })
|
|
await Promise.resolve()
|
|
expect(settled).toBe(false)
|
|
expect(bench.loadOlder).not.toHaveBeenCalled()
|
|
// A notification while still cold keeps waiting.
|
|
bench.setOpenState('cold')
|
|
await Promise.resolve()
|
|
expect(settled).toBe(false)
|
|
bench.setOpenState('open')
|
|
await page
|
|
expect(bench.loadOlder).toHaveBeenCalledTimes(1)
|
|
// A failed open also settles instead of leaving the view waiting forever.
|
|
bench.setOpenState('error')
|
|
expect(await injected.loadOlder()).toBe('exhausted')
|
|
await bench.fiber.dispose()
|
|
})
|
|
|
|
it('single-flights older pages and keeps a busy or no-progress page retryable', async () => {
|
|
const bench = await mountPlugin()
|
|
const injectEntry = bench.entries()[0]!.inject as ((sessionId: SessionId) => StatsViewInjected) | undefined
|
|
const injected = injectEntry!(SID)
|
|
bench.setHasMore(true)
|
|
bench.loadOlder.mockImplementationOnce(() => undefined)
|
|
const first = injected.loadOlder()
|
|
const second = injected.loadOlder()
|
|
expect(second).toBe(first)
|
|
await Promise.resolve()
|
|
expect(bench.loadOlder).toHaveBeenCalledTimes(1)
|
|
expect(await first).toBe('retry')
|
|
expect(bench.loadOlder).toHaveBeenCalledTimes(1)
|
|
|
|
// Chat/Trajectory may own the next page; do not start a second request or
|
|
// claim exhaustion while the Session reports that page in flight.
|
|
bench.setLoadingOlder(true)
|
|
expect(await injected.loadOlder()).toBe('retry')
|
|
expect(bench.loadOlder).toHaveBeenCalledTimes(1)
|
|
bench.setLoadingOlder(false)
|
|
expect(await injected.loadOlder()).toBe('retry')
|
|
expect(bench.loadOlder).toHaveBeenCalledTimes(2)
|
|
await bench.fiber.dispose()
|
|
})
|
|
|
|
it('cancels a pending open-window wait without asking for a page', async () => {
|
|
const bench = await mountPlugin()
|
|
const injectEntry = bench.entries()[0]!.inject as ((sessionId: SessionId) => StatsViewInjected) | undefined
|
|
const injected = injectEntry!(SID)
|
|
bench.setOpenState('cold')
|
|
const page = injected.loadOlder()
|
|
injected.cancel()
|
|
expect(await page).toBe('retry')
|
|
expect(bench.loadOlder).not.toHaveBeenCalled()
|
|
await bench.fiber.dispose()
|
|
})
|
|
|
|
it('resolves prompt images through the shared Conversation image cache', async () => {
|
|
const bench = await mountPlugin()
|
|
const injectEntry = bench.entries()[0]!.inject as ((sessionId: SessionId) => StatsViewInjected) | undefined
|
|
const injected = injectEntry!(SID)
|
|
await expect(injected.loadImage(IMAGE)).resolves.toBe('blob:image')
|
|
expect(bench.imageUrl).toHaveBeenCalledWith(SID, IMAGE)
|
|
await bench.fiber.dispose()
|
|
})
|
|
|
|
it('serves the empty snapshot until the target publishes turns', async () => {
|
|
const bench = await mountPlugin()
|
|
const injectEntry = bench.entries()[0]!.inject as ((sessionId: SessionId) => StatsViewInjected) | undefined
|
|
expect(injectEntry).toBeDefined()
|
|
const injected = injectEntry!(SID)
|
|
expect(bench.binding).toHaveBeenCalledWith(SID)
|
|
expect(injected.hooks.stats.getSnapshot()).toBe(EMPTY_STATS_SNAPSHOT)
|
|
const published: StatsSnapshot = { turns: [], stepCount: 0 }
|
|
bench.setSnapshot(published)
|
|
expect(injected.hooks.stats.getSnapshot()).toBe(published)
|
|
const listener = vi.fn()
|
|
const unsubscribe = injected.hooks.stats.subscribe(listener)
|
|
bench.notify()
|
|
expect(listener).toHaveBeenCalledTimes(1)
|
|
unsubscribe()
|
|
// A page that grew the published snapshot reports progress.
|
|
bench.loadOlder.mockImplementationOnce(() => {
|
|
bench.setSnapshot({ turns: [], stepCount: 0 })
|
|
bench.notify()
|
|
})
|
|
expect(await injected.loadOlder()).toBe('grew')
|
|
expect(bench.loadOlder).toHaveBeenCalledTimes(1)
|
|
// A page that leaves the snapshot unchanged and has no history left is exhausted.
|
|
expect(await injected.loadOlder()).toBe('exhausted')
|
|
expect(bench.loadOlder).toHaveBeenCalledTimes(2)
|
|
// A no-progress page with older history left is surfaced, not hidden as exhausted.
|
|
bench.setHasMore(true)
|
|
expect(await injected.loadOlder()).toBe('retry')
|
|
// The source is cached per Session binding; a second injection reuses it.
|
|
expect(injectEntry!(SID).hooks.stats).toBe(injected.hooks.stats)
|
|
expect(() => injectEntry!('stats-missing' as SessionId)).toThrow(/unavailable/)
|
|
await bench.fiber.dispose()
|
|
})
|
|
})
|
|
|
|
describe('host entry', () => {
|
|
it('contributes no host-side behavior', () => {
|
|
expect(() => { hostApply() }).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('locale dictionaries', () => {
|
|
it('ships the same key set in both languages', () => {
|
|
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
|
|
expect(Object.values(en).every(value => value !== '')).toBe(true)
|
|
expect(en['view.stats']).toBe('Statistics')
|
|
expect(en['summary']).toContain('{turns}')
|
|
expect(en['loadOlder']).toBe('Load earlier')
|
|
})
|
|
})
|
|
|
|
describe('snapshot builder', () => {
|
|
it('starts empty and turns step nodes into turn rows', () => {
|
|
const builder = statsViewDefinition.create()
|
|
expect(builder.empty).toBe(EMPTY_STATS_SNAPSHOT)
|
|
const empty = builder.replace({ nodes: [], timeline: { turnOrder: [], turns: new Map() } })
|
|
expect(empty).toEqual({ turns: [], stepCount: 0 })
|
|
const snapshot = builder.replace({
|
|
nodes: [node('stats-step:1:1', { kind: 'step', metrics: measuredStep(1, 1, 1_000, 1_800) })],
|
|
timeline: { turnOrder: [1], turns: new Map() },
|
|
})
|
|
expect(snapshot.turns).toHaveLength(1)
|
|
expect(snapshot.turns[0]?.steps).toHaveLength(1)
|
|
expect(snapshot.stepCount).toBe(1)
|
|
})
|
|
|
|
it('keeps the current snapshot for an empty incremental batch', () => {
|
|
const builder = statsViewDefinition.create()
|
|
const first = builder.apply({ upserts: [], timeline: { turnOrder: [], turns: new Map() } })
|
|
expect(first).toBe(EMPTY_STATS_SNAPSHOT)
|
|
expect(builder.apply({ upserts: [], timeline: { turnOrder: [], turns: new Map() } })).toBe(first)
|
|
const upserted = builder.apply({
|
|
upserts: [
|
|
node('stats-prompt:1:0', { kind: 'prompt', turn: 1, prompt: '问', images: [IMAGE], source: 'user', sender: null }),
|
|
node('stats-step:1:1', { kind: 'step', metrics: measuredStep(1, 1, 1_000, 1_800) }),
|
|
node('stats-turn-end:1:100000', { kind: 'end', turn: 1, closeKind: 'completed', endTime: 1_800 }),
|
|
],
|
|
timeline: { turnOrder: [1], turns: new Map() },
|
|
})
|
|
expect(upserted.turns[0]?.prompt).toBe('问')
|
|
expect(upserted.stepCount).toBe(1)
|
|
})
|
|
|
|
it('registers its target through the conversation view registry', () => {
|
|
const register = vi.fn(() => () => undefined)
|
|
const ctx = new Context()
|
|
ctx.provide('uiConversation', { views: { register } } as never)
|
|
registerStatsConversationView(ctx)
|
|
expect(register).toHaveBeenCalledWith(statsViewDefinition)
|
|
})
|
|
})
|
|
|
|
describe('conversation Definitions', () => {
|
|
it('publishes one prompt, one step, and one turn end from a live window', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const snapshot = assemble(definitions, [
|
|
entry(event('turn/start', { turn: 1 })),
|
|
entry(event('user/message', {
|
|
role: 'user', id: 'u1', content: [{ type: 'text', text: '看看这个' }, { type: 'image', attachment: IMAGE }], source: { kind: 'user' },
|
|
})),
|
|
entry(event('step/start', { turn: 1, step: 1 }, 2_000)),
|
|
transient(1, 1, delta, 2_100),
|
|
entry(event('assistant/message', {
|
|
turn: 1,
|
|
step: 1,
|
|
message: { id: 'm1', role: 'assistant', content: [], source: { provider: 'p', model: 'm' } },
|
|
stream: stream(2_300),
|
|
usage,
|
|
}, 2_400)),
|
|
entry(event('tool/call', {
|
|
turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{"file_path":"/a/b/c.ts"}',
|
|
}, 2_500)),
|
|
entry(event('tool/result', {
|
|
turn: 1, step: 1, message: { role: 'tool', content: [{ type: 'text', text: 'one\ntwo' }], source: { callId: 'call-1' } },
|
|
}, 2_700)),
|
|
entry(event('step/end', { turn: 1, step: 1 }, 2_800)),
|
|
entry(event('turn/end', { turn: 1, reason: { kind: 'completed' } }, 2_800)),
|
|
])
|
|
expect(snapshot?.turns).toHaveLength(1)
|
|
expect(snapshot?.stepCount).toBe(1)
|
|
const turn = snapshot!.turns[0]!
|
|
expect(turn.prompt).toBe('看看这个')
|
|
expect(turn.promptImages).toEqual([IMAGE])
|
|
expect(turn.interrupted).toBe(false)
|
|
expect(turn.steps[0]?.startTime).toBe(2_000)
|
|
expect(turn.steps[0]?.calls).toHaveLength(1)
|
|
expect(turn.steps[0]?.toolOutput).toBe(7)
|
|
})
|
|
|
|
it('closes an interrupted tail whose last step never ended', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const snapshot = assemble(definitions, [
|
|
entry(event('turn/start', { turn: 2 })),
|
|
entry(event('step/start', { turn: 2, step: 1 }, 5_000)),
|
|
transient(2, 1, delta, 5_100),
|
|
entry(event('turn/end', { turn: 2, reason: { kind: 'interrupted' } }, 5_800)),
|
|
])
|
|
const turn = snapshot!.turns[0]!
|
|
expect(turn.interrupted).toBe(true)
|
|
expect(turn.steps[0]?.endTime).toBe(5_800)
|
|
expect(turn.steps[0]?.wall).toBe(800)
|
|
expect(turn.steps[0]?.aborted).toBe(true)
|
|
expect(turn.steps[0]?.running).toBe(false)
|
|
})
|
|
|
|
it('ignores a compaction re-delivery for a turn the window does not hold', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const snapshot = assemble(definitions, [
|
|
entry(event('turn/start', { turn: 575 }, 8_000)),
|
|
entry(event('step/start', { turn: 575, step: 1 }, 8_100)),
|
|
entry(event('assistant/message', {
|
|
turn: 575,
|
|
step: 1,
|
|
message: { id: 'm', role: 'assistant', content: [], source: { provider: 'p', model: 'm' } },
|
|
stream: stream(8_200),
|
|
}, 8_200)),
|
|
// Compaction restores a pruned result under its original (unloaded) turn.
|
|
entry(event('tool/result', {
|
|
turn: 451, step: 1, message: { role: 'tool', content: [], source: { callId: 'call-old' } },
|
|
}, 8_300)),
|
|
])
|
|
expect(snapshot?.turns.map(turn => turn.turn)).toEqual([575])
|
|
expect(snapshot?.stepCount).toBe(1)
|
|
})
|
|
|
|
it('folds a trimmed window from the update events it still holds', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const snapshot = assemble(definitions, [
|
|
entry(event('user/message', {
|
|
role: 'user', id: 'u2', content: [{ type: 'text', text: '问题' }], source: { kind: 'user' },
|
|
})),
|
|
entry(event('assistant/message', {
|
|
turn: 4,
|
|
step: 1,
|
|
message: { id: 'm2', role: 'assistant', content: [], source: { provider: 'p', model: 'm' } },
|
|
stream: stream(7_300),
|
|
usage,
|
|
}, 7_400)),
|
|
entry(event('tool/call', {
|
|
turn: 4, step: 1, callId: 'call-2', name: 'grep', arguments: '{"pattern":"x","include":"*.ts"}',
|
|
}, 7_500)),
|
|
entry(event('tool/result', {
|
|
turn: 4, step: 1, message: { role: 'tool', content: [{ type: 'text', text: 'hit' }], source: { callId: 'call-2' } },
|
|
}, 7_600)),
|
|
entry(event('turn/end', { turn: 4, reason: { kind: 'completed' } }, 7_700)),
|
|
])
|
|
expect(snapshot?.turns).toHaveLength(1)
|
|
const step = snapshot!.turns[0]!.steps[0]!
|
|
expect(step.startTime).toBeNull()
|
|
expect(step.context).toBe(600)
|
|
expect(step.tools).toEqual([{ name: 'grep', count: 1 }])
|
|
expect(snapshot!.turns[0]!.prompt).toBeNull()
|
|
})
|
|
|
|
it('publishes a compaction row with its summary call facts', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const snapshot = assemble(definitions, [
|
|
entry(event('turn/start', { turn: 6 })),
|
|
entry(event('step/start', { turn: 6, step: 1 }, 5_000)),
|
|
entry(event('user/message', {
|
|
content: [{ type: 'text', text: 'go' }],
|
|
source: { kind: 'user' },
|
|
}, 5_100)),
|
|
entry(event('compaction/start', { compactionId: '', turn: 6 }, 4_800)),
|
|
entry(event('compaction/start', { compactionId: 'cmp-0', turn: null }, 4_900)),
|
|
entry(event('compaction/summary', {
|
|
compactionId: 'cmp-0',
|
|
turn: 6,
|
|
summary: [{ type: 'text', text: 'older summary' }],
|
|
shadowedRange: { start: 1 as SessionSeq, end: 2 as SessionSeq },
|
|
shadowedSeqs: [],
|
|
shadowedTokenCount: 1_000,
|
|
provider: 'p',
|
|
model: 'm',
|
|
llmStreamCall: false,
|
|
}, 5_000)),
|
|
entry(event('compaction/end', { compactionId: 'cmp-0', turn: 6 }, 5_100)),
|
|
entry(event('compaction/start', { compactionId: 'another', turn: null }, 5_150)),
|
|
entry(event('compaction/end', { compactionId: 'another', turn: null }, 5_160)),
|
|
entry(event('compaction/start', { compactionId: 'cmp-1', turn: 6 }, 5_200)),
|
|
entry(event('compaction/summary', {
|
|
compactionId: 'cmp-1',
|
|
turn: 6,
|
|
summary: [{ type: 'text', text: 'sum' }, { type: 'reasoning', text: 'why' }],
|
|
shadowedRange: { start: 1 as SessionSeq, end: 2 as SessionSeq },
|
|
shadowedSeqs: [],
|
|
shadowedTokenCount: 4_000,
|
|
provider: 'deepseek-official',
|
|
model: 'deepseek-flash',
|
|
usage: { inputTokens: 1_000, cacheReadTokens: 100, outputTokens: 50 },
|
|
llmStreamCall: false,
|
|
}, 5_300)),
|
|
entry(event('compaction/end', { compactionId: 'cmp-1', turn: 6 }, 5_400)),
|
|
entry(event('step/end', { turn: 6, step: 1 }, 5_500)),
|
|
entry(event('turn/end', { turn: 6, reason: { kind: 'completed' } }, 5_600)),
|
|
])
|
|
expect(snapshot?.turns).toHaveLength(1)
|
|
expect(snapshot?.turns[0]?.compactions).toHaveLength(2)
|
|
expect(snapshot?.turns[0]?.compactions.find(entry => entry.compactionId === 'cmp-0')).toMatchObject({
|
|
turn: 6, context: null, output: null, cacheHit: null, summaryChars: 13,
|
|
})
|
|
expect(snapshot?.turns[0]?.compactions[1]).toEqual({
|
|
compactionId: 'cmp-1',
|
|
turn: 6,
|
|
startTime: 5_200,
|
|
endTime: 5_400,
|
|
wall: 200,
|
|
running: false,
|
|
context: 1_100,
|
|
output: 50,
|
|
cacheHit: 100 / 1_100,
|
|
cacheRead: 100,
|
|
cacheWrite: null,
|
|
provider: 'deepseek-official',
|
|
model: 'deepseek-flash',
|
|
shadowedTokens: 4_000,
|
|
summaryChars: 3,
|
|
error: null,
|
|
})
|
|
|
|
const interrupted = assemble(definitions, [
|
|
entry(event('turn/start', { turn: 7 })),
|
|
entry(event('step/start', { turn: 7, step: 1 }, 6_000)),
|
|
entry(event('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, 6_010)),
|
|
entry(event('compaction/start', { compactionId: 'cmp-2', turn: 7 }, 6_100)),
|
|
entry(event('compaction/end', { compactionId: 'cmp-2', turn: 7, error: 'aborted' }, 6_200)),
|
|
entry(event('turn/end', { turn: 7, reason: { kind: 'completed' } }, 6_300)),
|
|
])
|
|
expect(interrupted?.turns[0]?.compactions[0]?.error).toBe('aborted')
|
|
expect(interrupted?.turns[0]?.compactions[0]?.running).toBe(false)
|
|
|
|
const running = assemble(definitions, [
|
|
entry(event('turn/start', { turn: 8 })),
|
|
entry(event('step/start', { turn: 8, step: 1 }, 6_400)),
|
|
entry(event('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, 6_410)),
|
|
entry(event('compaction/start', { compactionId: 'cmp-3', turn: 8 }, 6_500)),
|
|
entry(event('step/end', { turn: 8, step: 1 }, 6_600)),
|
|
entry(event('turn/end', { turn: 8, reason: { kind: 'interrupted' } }, 6_700)),
|
|
])
|
|
expect(running?.turns[0]?.compactions[0]).toMatchObject({
|
|
running: true, wall: null, context: null, provider: null, shadowedTokens: null, summaryChars: null, error: null,
|
|
})
|
|
})
|
|
|
|
it('publishes request, prompt, tool-set, and prune event rows', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const snapshot = assemble(definitions, [
|
|
entry(event('compaction/prune', {
|
|
shadowedRange: { start: 1, end: 2 }, shadowedSeqs: [], shadowedTokenCount: 10,
|
|
}, 6_900)),
|
|
entry(event('turn/start', { turn: 9 })),
|
|
entry(event('request/header', {
|
|
header: { config: { provider: 'p', model: 'm', reasoningEffort: 'max', maxTokens: 1_000 } },
|
|
reason: 'initial',
|
|
}, 6_950)),
|
|
entry(event('step/start', { turn: 9, step: 1 }, 7_000)),
|
|
entry(event('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, 7_010)),
|
|
entry(event('request/header', {
|
|
header: { config: { provider: 'p', model: 'm', reasoningEffort: 'max', maxTokens: 1_000 }, tools: [{ name: 'read' }] },
|
|
reason: 'series',
|
|
startsSeries: true,
|
|
}, 7_100)),
|
|
entry(surfaceEvent('system/message', {
|
|
turn: 9,
|
|
step: 1,
|
|
message: { role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'prompt' }] },
|
|
}, 'append', 7_200)),
|
|
entry(surfaceEvent('system/message', {
|
|
turn: 9,
|
|
step: 1,
|
|
message: { role: 'system', source: { kind: 'system-prompt' }, content: [] },
|
|
}, 'replace', 7_300)),
|
|
entry(event('developer/message', {
|
|
turn: 9,
|
|
step: 1,
|
|
message: {
|
|
role: 'developer',
|
|
source: { kind: 'tool-registry' },
|
|
content: [{ type: 'tool-addition', toolName: 'read' }, { type: 'tool-removal', toolName: 'edit' }],
|
|
},
|
|
}, 7_400)),
|
|
entry(event('developer/message', {
|
|
turn: 9,
|
|
step: 1,
|
|
message: { role: 'developer', source: { kind: 'plugin' }, content: [{ type: 'text', text: 'note' }] },
|
|
}, 7_450)),
|
|
entry(event('developer/message', {
|
|
turn: 9,
|
|
step: 1,
|
|
message: { role: 'developer', source: { kind: 'tool-registry' }, content: [{ type: 'text', text: 'noop' }] },
|
|
}, 7_480)),
|
|
entry(event('compaction/prune', {
|
|
shadowedRange: { start: 1, end: 2 }, shadowedSeqs: [], shadowedTokenCount: 5_000,
|
|
}, 7_600)),
|
|
entry(event('step/end', { turn: 9, step: 1 }, 7_700)),
|
|
entry(event('turn/end', { turn: 9, reason: { kind: 'completed' } }, 7_800)),
|
|
])
|
|
expect(snapshot?.turns).toHaveLength(1)
|
|
const events = snapshot!.turns[0]!.events
|
|
expect(events.map(event => event.kind)).toEqual([
|
|
'request-header', 'request-header', 'system-prompt', 'system-prompt', 'tool-set', 'context-prune',
|
|
])
|
|
expect(events[0]).toMatchObject({ kind: 'request-header', turn: 9, step: null, toolCount: null })
|
|
expect(events[1]).toMatchObject({
|
|
reason: 'series', series: true, provider: 'p', model: 'm', toolCount: 1, turn: 9, step: 1,
|
|
})
|
|
expect(events[2]).toMatchObject({ kind: 'system-prompt', surfaceOp: 'append', chars: 6 })
|
|
expect(events[3]).toMatchObject({ kind: 'system-prompt', surfaceOp: 'clear', chars: 0 })
|
|
expect(events[4]).toMatchObject({ kind: 'tool-set', added: ['read'], removed: ['edit'] })
|
|
expect(events[5]).toMatchObject({ kind: 'context-prune', shadowedTokens: 5_000 })
|
|
|
|
const turnLocation = { kind: 'turn', turn: { turn: 9 } } as Parameters<typeof eventMetrics>[1]
|
|
expect(eventMetrics(event('request/header', {
|
|
header: { config: { provider: 'p', model: 'm' } }, reason: 'initial',
|
|
}), turnLocation)).toMatchObject({ turn: 9, step: null, toolCount: null })
|
|
expect(eventMetrics(surfaceEvent('system/message', {
|
|
message: { role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'reasoning', text: 'r' }] },
|
|
}, 'replace'), undefined)).toMatchObject({ surfaceOp: 'clear', chars: 0 })
|
|
expect(eventMetrics(surfaceEvent('system/message', {
|
|
message: { role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'next' }] },
|
|
}, 'replace'), undefined)).toMatchObject({ surfaceOp: 'replace', chars: 4 })
|
|
expect(eventMetrics(event('developer/message', {
|
|
message: { role: 'developer', source: { kind: 'tool-registry' }, content: [{ type: 'text', text: 'noop' }] },
|
|
}), undefined)).toBeNull()
|
|
expect(eventMetrics(event('developer/message', {
|
|
message: {
|
|
role: 'developer',
|
|
source: { kind: 'tool-registry' },
|
|
content: [{ type: 'text', text: 'x', toolName: 'unknown' }],
|
|
},
|
|
}), undefined)).toBeNull()
|
|
})
|
|
|
|
it('normalizes lifecycle events for the event rows', () => {
|
|
const turnLocation = { kind: 'turn', turn: { turn: 9 } } as Parameters<typeof eventMetrics>[1]
|
|
expect(eventMetrics(event('request/context', {
|
|
provider: 'p', model: 'm', contextWindow: 128_000, systemPromptUpdate: 'in-history',
|
|
}), turnLocation)).toMatchObject({
|
|
kind: 'request-context', turn: 9, step: null, provider: 'p', model: 'm', status: 'in-history', chars: 128_000,
|
|
})
|
|
expect(eventMetrics(event('request/context', { provider: 'p', model: 'm' }), undefined)).toMatchObject({
|
|
status: null, chars: null,
|
|
})
|
|
expect(eventMetrics(event('llm/retry', {
|
|
retryId: 'r1', turn: 3, step: 2, provider: 'p', mode: 'normal', policyKey: 'k', retry: 2, maxRetries: 5,
|
|
delayMs: 1_500, failure: { message: 'rate limited', code: 'RATE_LIMIT' },
|
|
}), undefined)).toMatchObject({
|
|
kind: 'model-retry', turn: 3, step: 2, provider: 'p', status: 'normal', retry: 2, delayMs: 1_500,
|
|
error: 'rate limited',
|
|
})
|
|
expect(eventMetrics(event('assistant/attempt', { turn: 3, step: 2, stream: [] }), undefined)).toMatchObject({
|
|
kind: 'model-attempt', turn: 3, step: 2, status: 'attempt',
|
|
})
|
|
expect(eventMetrics(surfaceEvent('user/message', {
|
|
role: 'user', source: { kind: 'runtime-context' }, content: [{ type: 'text', text: 'ctx' }],
|
|
}, 'append'), undefined)).toMatchObject({ kind: 'context-refresh', status: 'runtime-context', chars: 3 })
|
|
expect(eventMetrics(surfaceEvent('user/message', {
|
|
role: 'user', source: { kind: 'time-context' }, content: [],
|
|
}, 'append'), undefined)).toMatchObject({ kind: 'context-refresh', status: 'time-context', chars: 0 })
|
|
expect(eventMetrics(surfaceEvent('user/message', {
|
|
role: 'user', source: { kind: 'user' }, content: [],
|
|
}, 'append'), undefined)).toBeNull()
|
|
expect(eventMetrics(event('approval/asked', {
|
|
id: 'a1', toolName: 'bash', reason: 'needs approval',
|
|
}), turnLocation)).toMatchObject({
|
|
kind: 'approval', turn: 9, step: null, status: 'asked', label: 'bash', error: 'needs approval',
|
|
})
|
|
expect(eventMetrics(event('approval/asked', { id: 'a2', toolName: 'read' }), undefined)).toMatchObject({
|
|
kind: 'approval', status: 'asked', label: 'read', error: null,
|
|
})
|
|
expect(eventMetrics(event('approval/decided', { id: 'a1', outcome: 'rejected' }), turnLocation)).toMatchObject({
|
|
kind: 'approval', turn: 9, status: 'rejected',
|
|
})
|
|
})
|
|
|
|
it('attributes Code-Mode dispatch events through the root call table', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const snapshot = assemble(definitions, [
|
|
entry(event('turn/start', { turn: 5 })),
|
|
entry(event('step/start', { turn: 5, step: 1 }, 8_000)),
|
|
entry(event('tool/call', { turn: 5, step: 1, callId: 'root-1', name: 'run_code', arguments: '{}' }, 8_100)),
|
|
entry(event('tool/ptc-dispatch-start', {
|
|
rootCallId: 'root-1', parentCallId: 'root-1', subCallId: 'sub-1', name: 'grep', arguments: '{"pattern":"x"}',
|
|
}, 8_200)),
|
|
entry(event('tool/ptc-dispatch', {
|
|
rootCallId: 'root-1', parentCallId: 'root-1', subCallId: 'sub-1', name: 'grep',
|
|
arguments: '{"pattern":"x"}', isError: false, content: [{ type: 'text', text: 'hit' }],
|
|
}, 8_300)),
|
|
entry(event('step/end', { turn: 5, step: 1 }, 8_400)),
|
|
entry(event('turn/end', { turn: 5, reason: { kind: 'completed' } }, 8_400)),
|
|
])
|
|
const step = snapshot!.turns[0]!.steps[0]!
|
|
expect(step.calls).toHaveLength(1)
|
|
expect(step.calls[0]?.inner).toBe(true)
|
|
expect(step.calls[0]?.name).toBe('grep')
|
|
expect(step.tools).toEqual([{ name: 'grep', count: 1 }])
|
|
})
|
|
|
|
it('incrementally applies the live tail after the window', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const snapshot = assemble(
|
|
definitions,
|
|
[entry(event('turn/start', { turn: 6 })), entry(event('step/start', { turn: 6, step: 1 }, 9_000))],
|
|
{
|
|
upserts: [
|
|
entry(event('assistant/message', {
|
|
turn: 6,
|
|
step: 1,
|
|
message: { id: 'm3', role: 'assistant', content: [], source: { provider: 'p', model: 'm' } },
|
|
stream: stream(9_300),
|
|
usage,
|
|
}, 9_400)),
|
|
entry(event('step/end', { turn: 6, step: 1 }, 9_500)),
|
|
],
|
|
},
|
|
)
|
|
expect(snapshot?.stepCount).toBe(1)
|
|
expect(snapshot!.turns[0]!.steps[0]!.ttft).toBe(300)
|
|
})
|
|
|
|
it('publishes a prompt node from a human-typed user message location', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const prompt = definitions[1] as ConversationNodeDefinition<StatsStepState>
|
|
expect(prompt.match(event('turn/start', { turn: 9 }))).toBeNull()
|
|
const runtime = event('user/message', {
|
|
role: 'user', id: 'u4', content: [{ type: 'text', text: '<system-reminder>' }], source: { kind: 'runtime-context' },
|
|
})
|
|
expect(prompt.match(runtime)).toEqual({ id: `${runtime.seq}`, role: 'start' })
|
|
const user = event('user/message', {
|
|
role: 'user', id: 'u6', content: [{ type: 'text', text: '继续' }, { type: 'image', attachment: IMAGE }], source: { kind: 'user' },
|
|
})
|
|
expect(prompt.match(user)).toEqual({ id: `${user.seq}`, role: 'start' })
|
|
const match = locatedStart(user, turnAt(9))
|
|
const state = prompt.start(contextOf(prompt, undefined), match, reader)
|
|
expect(state.turn).toBe(9)
|
|
expect(state.mark).toBe('prompt')
|
|
expect(state.prompt).toBe('继续')
|
|
expect(state.images).toEqual([IMAGE])
|
|
expect(state.trigger).toBe('user')
|
|
const node = prompt.buildViewNode!({
|
|
...contextOf(prompt, state, [match], match),
|
|
}) as StatsViewNode
|
|
expect(node.data).toEqual({ kind: 'prompt', turn: 9, prompt: '继续', images: [IMAGE], source: 'user', sender: null })
|
|
const context: ConversationNodeContext<StatsStepState> & { readonly state: StatsStepState } = {
|
|
...contextOf(prompt, state, [match], match),
|
|
state,
|
|
}
|
|
expect(prompt.update(context, match)).toBe(state)
|
|
})
|
|
|
|
it('takes a prompt turn from a step location and anchors without a start', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const prompt = definitions[1] as ConversationNodeDefinition<StatsStepState>
|
|
const user = event('user/message', {
|
|
role: 'user', id: 'u7', content: [{ type: 'text', text: '问题' }], source: { kind: 'user' },
|
|
})
|
|
const located = locatedStart(user, stepAt(4, 1))
|
|
const state = prompt.start(contextOf(prompt, undefined), located, reader)
|
|
expect(state.turn).toBe(4)
|
|
const update = locatedUpdate(user, stepAt(4, 1))
|
|
const node = prompt.buildViewNode!({
|
|
...contextOf(prompt, state, [update]),
|
|
}) as StatsViewNode
|
|
expect(node.anchorSeq).toBe(0)
|
|
expect(node.data).toMatchObject({ kind: 'prompt', turn: 4 })
|
|
// A message the window cannot place publishes no row.
|
|
expect(prompt.buildViewNode!({ ...contextOf(prompt, state) })).toBeNull()
|
|
})
|
|
|
|
it('falls back to the generic trigger when the source kind is unrecorded', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const prompt = definitions[1] as ConversationNodeDefinition<StatsStepState>
|
|
const user = event('user/message', {
|
|
role: 'user', id: 'u8', content: [{ type: 'text', text: '问题' }], source: { kind: 'user' },
|
|
})
|
|
const match = locatedStart(user, turnAt(3))
|
|
const state = { ...prompt.start(contextOf(prompt, undefined), match, reader), trigger: null }
|
|
const node = prompt.buildViewNode!({
|
|
...contextOf(prompt, state, [match], match),
|
|
}) as StatsViewNode
|
|
expect(node.data).toMatchObject({ kind: 'prompt', source: 'other' })
|
|
})
|
|
|
|
it('folds Code-Mode dispatches into one node per root call', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const codeMode = definitions[3] as ConversationNodeDefinition<StatsStepState>
|
|
const startEvent = event('tool/ptc-dispatch-start', {
|
|
rootCallId: 'root-1', parentCallId: 'root-1', subCallId: 'sub-1', name: 'grep', arguments: '{"pattern":"x"}',
|
|
}, 8_200)
|
|
const dispatch = event('tool/ptc-dispatch', {
|
|
rootCallId: 'root-1', parentCallId: 'root-1', subCallId: 'sub-1', name: 'grep',
|
|
arguments: '{"pattern":"x"}', isError: false, content: [{ type: 'text', text: 'hit' }],
|
|
}, 8_300)
|
|
expect(codeMode.match(startEvent)).toEqual({ id: 'code:root-1', role: 'start' })
|
|
expect(codeMode.match(dispatch)).toEqual({ id: 'code:root-1', role: 'update' })
|
|
expect(codeMode.match(event('step/start', { turn: 1, step: 1 }))).toBeNull()
|
|
const start = locatedStart(startEvent, stepAt(5, 1))
|
|
const update = locatedUpdate(dispatch, stepAt(5, 1))
|
|
const state = codeMode.start(contextOf(codeMode, undefined), start, reader)
|
|
expect(state.starts).toEqual({ 'sub-1': 8_200 })
|
|
const context: ConversationNodeContext<StatsStepState> & { readonly state: StatsStepState } = {
|
|
...contextOf(codeMode, state, [start, update], start),
|
|
state,
|
|
}
|
|
const folded = codeMode.update(context, update)
|
|
expect(folded.calls?.['sub-1']?.name).toBe('grep')
|
|
const node = codeMode.buildViewNode!({
|
|
...contextOf(codeMode, folded, [start, update], start),
|
|
}) as StatsViewNode
|
|
expect(node.data).toMatchObject({ kind: 'inner', turn: 5, step: 1 })
|
|
const inner = node.data
|
|
if (inner.kind !== 'inner') throw new Error('expected an inner contribution')
|
|
expect(inner.calls.map(call => call.name)).toEqual(['grep'])
|
|
expect(inner.calls[0]?.ms).toBe(100)
|
|
// Without a step location the contribution has no row to merge into.
|
|
expect(codeMode.buildViewNode!({
|
|
...contextOf(codeMode, folded, [start, update], locatedStart(startEvent, turnAt(5))),
|
|
})).toBeNull()
|
|
})
|
|
|
|
it('publishes an inner node only when the window still knows its step', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const codeMode = definitions[3] as ConversationNodeDefinition<StatsStepState>
|
|
const startEvent = event('tool/ptc-dispatch-start', {
|
|
rootCallId: 'root-2', parentCallId: 'root-2', subCallId: 'sub-2', name: 'read', arguments: '{}',
|
|
}, 6_200)
|
|
const dispatch = event('tool/ptc-dispatch', {
|
|
rootCallId: 'root-2', parentCallId: 'root-2', subCallId: 'sub-2', name: 'read',
|
|
arguments: '{}', isError: false, content: [],
|
|
}, 6_300)
|
|
const start = locatedStart(startEvent, stepAt(6, 1))
|
|
const update = locatedUpdate(dispatch, stepAt(6, 1))
|
|
const state = codeMode.start(contextOf(codeMode, undefined), start, reader)
|
|
const folded = codeMode.update({
|
|
...contextOf(codeMode, state, [start, update], start),
|
|
state,
|
|
}, update)
|
|
// Without a start match the node anchors at zero and takes the last location.
|
|
const noStart = codeMode.buildViewNode!({
|
|
...contextOf(codeMode, folded, [update]),
|
|
}) as StatsViewNode
|
|
expect(noStart.anchorSeq).toBe(0)
|
|
expect(noStart.data).toMatchObject({ kind: 'inner', turn: 6, step: 1 })
|
|
// A turn-only location, or no location evidence, publishes no row.
|
|
expect(codeMode.buildViewNode!({
|
|
...contextOf(codeMode, folded, [locatedUpdate(dispatch, turnAt(6))]),
|
|
})).toBeNull()
|
|
expect(codeMode.buildViewNode!({ ...contextOf(codeMode, folded) })).toBeNull()
|
|
// The node still merges even when the dispatch lost its own start.
|
|
const lostStart = { ...folded, calls: { 'sub-2': { ...folded.calls!['sub-2']!, ms: null, startedAt: null } } }
|
|
const node = codeMode.buildViewNode!({
|
|
...contextOf(codeMode, lostStart, [update]),
|
|
}) as StatsViewNode
|
|
expect(node.data).toMatchObject({ kind: 'inner', turn: 6, step: 1 })
|
|
})
|
|
|
|
it('keeps a Code-Mode start unplaced when the window has no step location', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const codeMode = definitions[3] as ConversationNodeDefinition<StatsStepState>
|
|
const startEvent = event('tool/ptc-dispatch-start', {
|
|
rootCallId: 'root-3', parentCallId: 'root-3', subCallId: 'sub-3', name: 'grep', arguments: '{}',
|
|
}, 7_200)
|
|
const turnOnly = codeMode.start(
|
|
contextOf(codeMode, undefined),
|
|
locatedStart(startEvent, turnAt(7)),
|
|
reader,
|
|
)
|
|
expect(turnOnly.turn).toBe(0)
|
|
expect(turnOnly.step).toBe(0)
|
|
const unlocated = codeMode.start(
|
|
contextOf(codeMode, undefined),
|
|
{ ...startMatch(startEvent), location: undefined as never },
|
|
reader,
|
|
)
|
|
expect(unlocated.turn).toBe(0)
|
|
// An update that arrives before any start keeps the absent state.
|
|
const orphan = event('tool/ptc-dispatch', {
|
|
rootCallId: 'root-4', parentCallId: 'root-4', subCallId: 'sub-4', name: 'read',
|
|
arguments: '{}', isError: false, content: [],
|
|
}, 7_300)
|
|
expect(codeMode.update(contextOf(codeMode, undefined) as never, updateMatch(orphan))).toBeUndefined()
|
|
})
|
|
|
|
it('rejects a start callback handed the wrong lifecycle event', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const wrong = updateMatch(event('assistant/message', { turn: 1, step: 1 }))
|
|
for (const definition of definitions) {
|
|
expect(() => definition.start(
|
|
contextOf(definition as ConversationNodeDefinition<StatsStepState>, undefined),
|
|
wrong as ConversationStartMatch,
|
|
reader,
|
|
)).toThrow(/requires/)
|
|
}
|
|
})
|
|
|
|
it('publishes no view node for a Context without state or evidence', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
for (const definition of definitions) {
|
|
expect(definition.buildViewNode!(contextOf(definition as ConversationNodeDefinition<StatsStepState>, undefined))).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('names the update cadence of live chunks and start events', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const step = definitions[0] as ConversationNodeDefinition<StatsStepState>
|
|
expect(step.publication!(startMatch(event('step/start', { turn: 1, step: 1 })))).toBe('none')
|
|
expect(step.publication!(updateMatch(transient(1, 1, delta).event))).toBe('none')
|
|
expect(step.publication!(updateMatch(event('assistant/attempt', { turn: 1, step: 1 })))).toBe('none')
|
|
expect(step.publication!(updateMatch(event('step/end', { turn: 1, step: 1 })))).toBe('immediate')
|
|
const prompt = definitions[1] as ConversationNodeDefinition<StatsStepState>
|
|
expect(prompt.publication!(startMatch(event('user/message', {
|
|
role: 'user', id: 'u', content: [], source: { kind: 'user' },
|
|
})))).toBe('immediate')
|
|
expect(Object.hasOwn(definitions[3]!, 'publication')).toBe(false)
|
|
})
|
|
|
|
it('anchors and locates a step node from its folded state', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const step = definitions[0] as ConversationNodeDefinition<StatsStepState>
|
|
const startEvent = event('step/start', { turn: 11, step: 2 }, 11_000)
|
|
const start = startMatch(startEvent)
|
|
const update = updateMatch(event('assistant/message', {
|
|
turn: 11,
|
|
step: 2,
|
|
message: { id: 'm', role: 'assistant', content: [], source: { provider: 'p', model: 'm' } },
|
|
stream: stream(11_300),
|
|
usage,
|
|
}, 11_400))
|
|
const state = step.start(
|
|
contextOf(step, undefined),
|
|
start,
|
|
reader,
|
|
)
|
|
const folded = step.update(
|
|
{
|
|
...contextOf(step, state, [start, update], start),
|
|
state,
|
|
},
|
|
update,
|
|
)
|
|
const withStart = step.buildViewNode!({
|
|
...contextOf(step, folded, [start, update], start),
|
|
}) as StatsViewNode
|
|
expect(withStart.anchorSeq).toBe(startEvent.seq)
|
|
expect(withStart.location).toEqual({ kind: 'unresolved' })
|
|
expect(withStart.data.kind).toBe('step')
|
|
const contribution = withStart.data
|
|
if (contribution.kind !== 'step') throw new Error('expected a step contribution')
|
|
expect(contribution.metrics.context).toBe(600)
|
|
expect(contribution.metrics.toolWall).toBeNull()
|
|
|
|
// Without a start match, the node anchors at 0 and takes the last match location.
|
|
const trimmed = step.buildViewNode!({
|
|
...contextOf(step,
|
|
{ ...folded, startSeq: null }, [{ ...update, location: stepAt(11, 2) }]),
|
|
}) as StatsViewNode
|
|
expect(trimmed.anchorSeq).toBe(0)
|
|
expect(trimmed.location).toEqual(stepAt(11, 2))
|
|
const unresolved = step.buildViewNode!({
|
|
...contextOf(step, { ...folded, startSeq: null }, []),
|
|
}) as StatsViewNode
|
|
expect(unresolved.location).toEqual({ kind: 'unresolved' })
|
|
})
|
|
|
|
it('matches every step lifecycle event by turn and step', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const step = definitions[0] as ConversationNodeDefinition<StatsStepState>
|
|
const cases: readonly [SessionEventLike, string][] = [
|
|
[event('step/start', { turn: 12, step: 3 }), '12:3'],
|
|
[event('step/end', { turn: 12, step: 3 }), '12:3'],
|
|
[event('assistant/attempt', { turn: 12, step: 3 }), '12:3'],
|
|
[event('assistant/message', { turn: 12, step: 3 }), '12:3'],
|
|
[event('llm/retry', { turn: 12, step: 3 }), '12:3'],
|
|
[event('tool/call', { turn: 12, step: 3, callId: 'c', name: 'read', arguments: '{}' }), '12:3'],
|
|
[event('tool/result', {
|
|
turn: 12, step: 3, message: { role: 'tool', content: [], source: { callId: 'c' }, toolCallId: 'c' },
|
|
}), '12:3'],
|
|
[transient(12, 3, delta).event, '12:3'],
|
|
]
|
|
for (const [fixture, id] of cases) {
|
|
expect(step.match(fixture)?.id).toBe(id)
|
|
}
|
|
expect(step.match(event('developer/message', { turn: 12, step: 3 }))).toBeNull()
|
|
expect(step.match(event('user/message', { role: 'user', id: 'u', content: [], source: { kind: 'user' } }))).toBeNull()
|
|
})
|
|
|
|
it('builds prompt and end markers from their folded states', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const end = definitions[2] as ConversationNodeDefinition<StatsStepState>
|
|
const endStart = startMatch(event('turn/end', { turn: 13, reason: { kind: 'interrupted' } }, 13_500))
|
|
const ended = end.start(
|
|
contextOf(end, undefined),
|
|
endStart,
|
|
reader,
|
|
)
|
|
expect(ended.closed).toBe(true)
|
|
expect(ended.closeKind).toBe('interrupted')
|
|
const endNode = end.buildViewNode!({
|
|
...contextOf(end, ended, [], endStart),
|
|
}) as StatsViewNode
|
|
expect(endNode.data).toEqual({ kind: 'end', turn: 13, closeKind: 'interrupted', endTime: 13_500 })
|
|
expect(end.match(event('step/start', { turn: 13, step: 1 }))).toBeNull()
|
|
})
|
|
|
|
it('keeps the end state and anchors it at zero without a recorded start', async () => {
|
|
const definitions = await registeredDefinitions()
|
|
const end = definitions[2] as ConversationNodeDefinition<StatsStepState>
|
|
const endState = end.start(
|
|
contextOf(end, undefined),
|
|
startMatch(event('turn/end', { turn: 14, reason: { kind: 'completed' } }, 14_500)),
|
|
reader,
|
|
)
|
|
const endContext: ConversationNodeContext<StatsStepState> & { readonly state: StatsStepState } = {
|
|
...contextOf(end, endState),
|
|
state: endState,
|
|
}
|
|
expect(end.update(
|
|
endContext,
|
|
updateMatch(event('turn/end', { turn: 14, reason: { kind: 'completed' } })),
|
|
)).toBe(endState)
|
|
const endNode = end.buildViewNode!({
|
|
...contextOf(end, endState),
|
|
}) as StatsViewNode
|
|
expect(endNode.anchorSeq).toBe(0)
|
|
})
|
|
})
|