fix(experimental): attribute perf rows per Session and load history in place
This commit is contained in:
1 parent
9cd81a5dd3
commit
2917b99d5c
10 files changed
+1341
-1353
No files matched your search
@@ -28,8 +28,10 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-session-controller",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-renderer"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -45,6 +47,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:*",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
|
||||
@@ -53,7 +56,9 @@
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-executor-ptc": "workspace:*",
|
||||
"@deepseek-ai/dsh-tools": "workspace:*",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
@@ -23,8 +24,8 @@ import { PerfView, type PerfViewInjected } from './PerfView.tsx'
|
||||
export type { PerfKey } from './locales.ts'
|
||||
export type { PerfCall, PerfSnapshot, PerfStepMetrics, PerfTurnMetrics } from './perf-contract.ts'
|
||||
|
||||
/** Required services: the conversation slot registry, view registry, and locale. */
|
||||
export const inject = ['slots', 'uiConversation', 'locale']
|
||||
/** Required services: the conversation slot and view registries, Session paging, and the locale service. */
|
||||
export const inject = ['slots', 'sessions', 'uiConversation', 'locale']
|
||||
|
||||
/** Snapshot hook name; `InjectFace` turns it into the component's `usePerf` prop. */
|
||||
const HOOK = 'perf'
|
||||
@@ -36,6 +37,8 @@ const HOOK = 'perf'
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// Per-plugin-instance source cache; unloading the plugin drops the map with it.
|
||||
const sources = new WeakMap<SessionBinding, ObservableSnapshot<PerfSnapshot>>()
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'client-ui-perf: dictionaries')
|
||||
// Registration-time text (the tab label) reads through the bound translate as
|
||||
// a thunk, so it follows the active locale without re-registration.
|
||||
@@ -49,12 +52,28 @@ export function apply(ctx: Context): void {
|
||||
locale: NS,
|
||||
label: () => t('view.perf'),
|
||||
inject: (sessionId: SessionId): PerfViewInjected => {
|
||||
const binding = ctx.sessions.binding(sessionId)
|
||||
const session = binding?.session
|
||||
if (binding === undefined || session === undefined) {
|
||||
throw new Error(`client-ui-perf: session "${sessionId}" is unavailable`)
|
||||
}
|
||||
const target = ctx.uiConversation.binding(sessionId).target('perf')
|
||||
const perf: ObservableSnapshot<PerfSnapshot> = {
|
||||
getSnapshot: () => target.getSnapshot() ?? EMPTY_PERF_SNAPSHOT,
|
||||
subscribe: listener => target.subscribe(listener),
|
||||
let perf = sources.get(binding)
|
||||
if (perf === undefined) {
|
||||
perf = {
|
||||
getSnapshot: () => target.getSnapshot() ?? EMPTY_PERF_SNAPSHOT,
|
||||
subscribe: listener => target.subscribe(listener),
|
||||
}
|
||||
sources.set(binding, perf)
|
||||
}
|
||||
return {
|
||||
hooks: { [HOOK]: perf },
|
||||
loadOlder: async () => {
|
||||
const before = perf.getSnapshot()
|
||||
await session.loadOlder()
|
||||
return perf.getSnapshot() !== before
|
||||
},
|
||||
}
|
||||
return { hooks: { [HOOK]: perf } }
|
||||
},
|
||||
}, PerfView))
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
/** Turn aggregation over the step metrics a window holds. */
|
||||
|
||||
import type {
|
||||
PerfStepMetrics, PerfToolCount, PerfTurnMetrics, PerfViewNode,
|
||||
PerfCall, PerfContribution, PerfStepMetrics, PerfToolCount, PerfTurnMetrics, PerfViewNode,
|
||||
} from './perf-contract.ts'
|
||||
import { CALL_LIMIT, isCodeMode, toolSpanUnion } from './perf-fold.ts'
|
||||
import { isNumber } from './perf-usage.ts'
|
||||
|
||||
/** One Code-Mode root call's inner contribution, before merging into its step. */
|
||||
type InnerContribution = Extract<PerfContribution, { kind: 'inner' }>
|
||||
|
||||
/** Context measured last in the turn: steps without usage keep the previous value. */
|
||||
function turnContext(steps: readonly PerfStepMetrics[]): number | null {
|
||||
let context: number | null = null
|
||||
@@ -12,6 +16,58 @@ function turnContext(steps: readonly PerfStepMetrics[]): number | null {
|
||||
return context
|
||||
}
|
||||
|
||||
/** Cache breakdown measured last in the turn, from the same step whose hit rate the row shows. */
|
||||
function turnCache(steps: readonly PerfStepMetrics[]): { read: number | null; write: number | null } {
|
||||
let read: number | null = null
|
||||
let write: number | null = null
|
||||
for (const step of steps) {
|
||||
if (!isNumber(step.cacheHit)) continue
|
||||
read = step.cacheRead
|
||||
write = step.cacheWrite
|
||||
}
|
||||
return { read, write }
|
||||
}
|
||||
|
||||
/** Tool tallies by descending count; ties keep call order. */
|
||||
function tallyCalls(calls: readonly PerfCall[]): PerfToolCount[] {
|
||||
const counts = new Map<string, number>()
|
||||
for (const call of calls) {
|
||||
if (call.name === null) continue
|
||||
counts.set(call.name, (counts.get(call.name) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the Code-Mode dispatches of one step into its row. The runner call
|
||||
* disappears in favor of the real tools, so the row shows what the code called
|
||||
* and the characters those results pushed into the next prompt.
|
||||
* @param step - step row folded from native events only.
|
||||
* @param inner - the step's Code-Mode contributions.
|
||||
* @returns the merged row, or the original when no dispatch was recorded.
|
||||
*/
|
||||
function mergeInnerCalls(step: PerfStepMetrics, inner: readonly InnerContribution[]): PerfStepMetrics {
|
||||
if (inner.length === 0) return step
|
||||
const innerCalls = inner.flatMap(contribution => contribution.calls)
|
||||
const calls = [
|
||||
...step.calls.filter(call => !isCodeMode(call.name)),
|
||||
...innerCalls,
|
||||
]
|
||||
.sort((left, right) => left.seq - right.seq)
|
||||
.slice(0, CALL_LIMIT)
|
||||
const chars = calls.reduce((sum, call) => sum + (call.chars ?? 0), 0)
|
||||
return {
|
||||
...step,
|
||||
calls,
|
||||
tools: tallyCalls(calls),
|
||||
toolOutput: chars,
|
||||
toolWall: toolSpanUnion(calls),
|
||||
toolErrors: step.toolErrors + innerCalls.filter(call => call.error).length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate one turn's steps into the row values the table shows.
|
||||
* @param turn - turn number.
|
||||
@@ -30,6 +86,8 @@ export function aggregateTurn(turn: number, steps: readonly PerfStepMetrics[]):
|
||||
let toolSum = 0
|
||||
let toolPairable = 0
|
||||
let toolPending = 0
|
||||
let toolWallSum = 0
|
||||
let toolWallMeasured = false
|
||||
const tools = new Map<string, number>()
|
||||
for (const step of steps) {
|
||||
for (const item of step.tools) tools.set(item.name, (tools.get(item.name) ?? 0) + item.count)
|
||||
@@ -46,6 +104,10 @@ export function aggregateTurn(turn: number, steps: readonly PerfStepMetrics[]):
|
||||
toolSum += step.tool
|
||||
toolPairable += 1
|
||||
}
|
||||
if (isNumber(step.toolWall)) {
|
||||
toolWallSum += step.toolWall
|
||||
toolWallMeasured = true
|
||||
}
|
||||
toolPending += step.toolPending
|
||||
}
|
||||
const first = steps[0]
|
||||
@@ -68,6 +130,7 @@ export function aggregateTurn(turn: number, steps: readonly PerfStepMetrics[]):
|
||||
if (isNumber(step.toolOutput)) toolOutput += step.toolOutput
|
||||
if (isNumber(step.wall)) lastStepWall = step.wall
|
||||
}
|
||||
const cache = turnCache(steps)
|
||||
const all = steps.length
|
||||
const toolCounts: PerfToolCount[] = [...tools.entries()]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
@@ -86,9 +149,12 @@ export function aggregateTurn(turn: number, steps: readonly PerfStepMetrics[]):
|
||||
tps: decodeSum > 0 && outputSum > 0 ? outputSum / (decodeSum / 1000) : null,
|
||||
output: outputUnknown === all ? null : outputSum,
|
||||
tool: toolPairable === 0 ? null : toolSum,
|
||||
toolWall: toolWallMeasured ? toolWallSum : null,
|
||||
toolPending: toolPairable === 0 ? 0 : toolPending,
|
||||
tools: toolCounts,
|
||||
cacheHit,
|
||||
cacheRead: cache.read,
|
||||
cacheWrite: cache.write,
|
||||
retries,
|
||||
toolErrors,
|
||||
toolOutput,
|
||||
@@ -112,6 +178,8 @@ function isEmptyStep(step: PerfStepMetrics): boolean {
|
||||
|
||||
/**
|
||||
* Group published nodes into ordered turns, closing each turn with its markers.
|
||||
* Code-Mode contributions merge into the step that dispatched them, identified
|
||||
* by the root call id the node carries.
|
||||
* @param nodes - every node the target builder currently holds.
|
||||
* @returns turn rows ordered oldest first.
|
||||
*/
|
||||
@@ -120,12 +188,14 @@ export function buildTurns(nodes: Iterable<PerfViewNode>): PerfTurnMetrics[] {
|
||||
// turn/end marker: turn → end time and reason, so an interrupted tail closes.
|
||||
const closedAt = new Map<number, number>()
|
||||
const closedKind = new Map<number, string>()
|
||||
// User question per turn.
|
||||
// User question per turn; the first typed question wins over later retries.
|
||||
const prompts = new Map<number, { text: string; images: number }>()
|
||||
// Code-Mode dispatches per step, merged after the steps are ordered.
|
||||
const inner = new Map<string, InnerContribution[]>()
|
||||
for (const node of nodes) {
|
||||
const data = node.data
|
||||
if (data.kind === 'prompt') {
|
||||
if (data.prompt !== null && data.prompt !== '') {
|
||||
if (data.prompt !== null && data.prompt !== '' && !prompts.has(data.turn)) {
|
||||
prompts.set(data.turn, { text: data.prompt, images: data.images })
|
||||
}
|
||||
continue
|
||||
@@ -135,6 +205,13 @@ export function buildTurns(nodes: Iterable<PerfViewNode>): PerfTurnMetrics[] {
|
||||
if (data.closeKind !== null) closedKind.set(data.turn, data.closeKind)
|
||||
continue
|
||||
}
|
||||
if (data.kind === 'inner') {
|
||||
const key = `${data.turn}:${data.step}`
|
||||
const list = inner.get(key)
|
||||
if (list === undefined) inner.set(key, [data])
|
||||
else list.push(data)
|
||||
continue
|
||||
}
|
||||
const metrics = data.metrics
|
||||
if (!isNumber(metrics.turn)) continue
|
||||
const bucket = grouped.get(metrics.turn)
|
||||
@@ -145,7 +222,7 @@ export function buildTurns(nodes: Iterable<PerfViewNode>): PerfTurnMetrics[] {
|
||||
const order = [...grouped.keys()].sort((left, right) => left - right)
|
||||
order.forEach((turn, index) => {
|
||||
const bucket = grouped.get(turn)
|
||||
/* v8 ignore next -- order is built from grouped's own keys, so the lookup always resolves. */
|
||||
/* v8 ignore next -- turn came from the map's own keys, so the bucket always exists. */
|
||||
if (bucket === undefined) return
|
||||
const steps = bucket.slice().sort((left, right) => left.step - right.step)
|
||||
// A step followed by another step cannot still be running: a trimmed window
|
||||
@@ -170,7 +247,8 @@ export function buildTurns(nodes: Iterable<PerfViewNode>): PerfTurnMetrics[] {
|
||||
aborted: true,
|
||||
}
|
||||
}
|
||||
const aggregate = aggregateTurn(turn, steps)
|
||||
const merged = steps.map(step => mergeInnerCalls(step, inner.get(`${turn}:${step.step}`) ?? []))
|
||||
const aggregate = aggregateTurn(turn, merged)
|
||||
const prompt = prompts.get(turn)
|
||||
turns.push({
|
||||
...aggregate,
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface PerfCall {
|
||||
readonly truncated: boolean
|
||||
/** Measured tool duration, or null while the result is missing. */
|
||||
readonly ms: number | null
|
||||
/** Wall-clock start of the call, for the non-overlapping tool-span union. */
|
||||
readonly startedAt: number | null
|
||||
/** Wall-clock end of the call, present only after its result settled. */
|
||||
readonly endedAt: number | null
|
||||
}
|
||||
|
||||
/** Tool call tally shown as one pill. */
|
||||
@@ -84,6 +88,8 @@ export interface PerfStepMetrics {
|
||||
readonly tps: number | null
|
||||
readonly output: number | null
|
||||
readonly tool: number | null
|
||||
/** Union of the paired calls' wall intervals; tool calls may run in parallel. */
|
||||
readonly toolWall: number | null
|
||||
readonly tools: readonly PerfToolCount[]
|
||||
readonly calls: readonly PerfCall[]
|
||||
readonly toolOutput: number | null
|
||||
@@ -119,9 +125,14 @@ export interface PerfTurnMetrics {
|
||||
readonly tps: number | null
|
||||
readonly output: number | null
|
||||
readonly tool: number | null
|
||||
/** Union of the turn's step tool spans; steps never overlap. */
|
||||
readonly toolWall: number | null
|
||||
readonly toolPending: number
|
||||
readonly tools: readonly PerfToolCount[]
|
||||
readonly cacheHit: number | null
|
||||
/** Cache breakdown of the last step that reported usage, for the turn tooltip. */
|
||||
readonly cacheRead: number | null
|
||||
readonly cacheWrite: number | null
|
||||
readonly retries: number
|
||||
readonly toolErrors: number
|
||||
readonly toolOutput: number
|
||||
@@ -137,6 +148,13 @@ export interface PerfTurnMetrics {
|
||||
/** One published node: a step's metrics or a turn-level marker. */
|
||||
export type PerfContribution =
|
||||
| { readonly kind: 'step'; readonly metrics: PerfStepMetrics }
|
||||
| {
|
||||
/** Inner tool calls one Code-Mode root call dispatched; merged into its step. */
|
||||
readonly kind: 'inner'
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly calls: readonly PerfCall[]
|
||||
}
|
||||
| {
|
||||
readonly kind: 'prompt'
|
||||
readonly turn: number
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/**
|
||||
* Conversation Definitions producing the performance target's nodes: one step
|
||||
* context per `step/start`, one prompt marker per turn, one end marker per turn.
|
||||
* context per `step/start`, one prompt context per user message, one end marker
|
||||
* per turn, and one context per Code-Mode root call for its inner dispatches.
|
||||
*
|
||||
* Every callback is a pure function of the events it receives: turn and step
|
||||
* identity comes from the engine's match location, never from module state, so
|
||||
* one Definition set serves every Session binding and history paging
|
||||
* (`replaceWindow`/`prepend`) cannot misattribute events.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
@@ -9,7 +15,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { PerfStepState, PerfViewNode } from './perf-contract.ts'
|
||||
import {
|
||||
currentTurnOf, fallbackState, foldEvent, newState, ptcStepOf, stepMetrics,
|
||||
CALL_LIMIT, fallbackState, foldEvent, isCodeMode, newState, promptOf, stepMetrics,
|
||||
} from './perf-fold.ts'
|
||||
|
||||
/** Target name shared by every Definition and the view builder. */
|
||||
@@ -18,37 +24,52 @@ export const TARGET = 'perf'
|
||||
/** Definition kind of one measured step. */
|
||||
const STEP_KIND = 'perf-step'
|
||||
|
||||
/** Definition kind of a turn's prompt marker. */
|
||||
/** Definition kind of one user prompt. */
|
||||
const PROMPT_KIND = 'perf-prompt'
|
||||
|
||||
/** Definition kind of a turn's end marker. */
|
||||
const END_KIND = 'perf-turn-end'
|
||||
|
||||
/** Definition kind of one Code-Mode root call's inner dispatches. */
|
||||
const CODE_MODE_KIND = 'perf-code-mode'
|
||||
|
||||
/** Marker ids never collide with a step number. */
|
||||
const PROMPT_STEP = 0
|
||||
const END_STEP = 100_000
|
||||
|
||||
/** Turn and step a match's Location resolves, or null when the window lost them. */
|
||||
function locationTurnStep(
|
||||
location: ConversationLocation | undefined,
|
||||
): { readonly turn: number; readonly step: number | null } | null {
|
||||
if (location === undefined) return null
|
||||
if (location.kind === 'turn') return { turn: location.turn.turn, step: null }
|
||||
if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step }
|
||||
return null
|
||||
}
|
||||
|
||||
/** Location a published node carries: the start match's, else the newest match's. */
|
||||
function nodeLocation(context: ConversationNodeContext<PerfStepState>): ConversationLocation {
|
||||
return context.start?.location ?? context.matches.at(-1)?.location ?? { kind: 'unresolved' }
|
||||
}
|
||||
|
||||
/** Wrap one contribution in the target's node envelope. */
|
||||
function perfNode(
|
||||
context: ConversationNodeContext<PerfStepState>,
|
||||
anchorSeq: number,
|
||||
data: PerfViewNode['data'],
|
||||
): PerfViewNode {
|
||||
const location: ConversationLocation = context.start?.location
|
||||
?? context.matches.at(-1)?.location
|
||||
?? { kind: 'unresolved' }
|
||||
return {
|
||||
key: context.key,
|
||||
kind: context.kind,
|
||||
id: context.id,
|
||||
target: TARGET,
|
||||
anchorSeq,
|
||||
location,
|
||||
location: nodeLocation(context),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/** State a step context folds from the matches a trimmed window still holds. */
|
||||
/** State a step-like Context folds from the matches a trimmed window still holds. */
|
||||
function stepState(context: ConversationNodeContext<PerfStepState>): PerfStepState | undefined {
|
||||
return context.state ?? fallbackState(context.matches)
|
||||
}
|
||||
@@ -69,11 +90,6 @@ const stepDefinition: ConversationNodeDefinition<PerfStepState> = {
|
||||
case 'tool/call':
|
||||
case 'tool/result':
|
||||
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
|
||||
case 'tool/ptc-dispatch':
|
||||
case 'tool/ptc-dispatch-start': {
|
||||
const id = ptcStepOf(event.data.rootCallId)
|
||||
return id === null ? null : { id, role: 'update' }
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -102,38 +118,34 @@ const stepDefinition: ConversationNodeDefinition<PerfStepState> = {
|
||||
},
|
||||
}
|
||||
|
||||
/** The user question of one turn; `user/message` carries no turn of its own. */
|
||||
/** The user question of one turn: one Context per human-typed `user/message`. */
|
||||
const promptDefinition: ConversationNodeDefinition<PerfStepState> = {
|
||||
kind: PROMPT_KIND,
|
||||
target: TARGET,
|
||||
match: (event) => {
|
||||
if (event.type === 'turn/start') {
|
||||
return { id: `${event.data.turn}:${PROMPT_STEP}`, role: 'start' }
|
||||
}
|
||||
if (event.type !== 'user/message') return null
|
||||
const turn = currentTurnOf()
|
||||
const source: unknown = event.data.source
|
||||
if (turn === null || typeof source !== 'object' || source === null) return null
|
||||
return (source as { kind?: unknown }).kind === 'user'
|
||||
? { id: `${turn}:${PROMPT_STEP}`, role: 'update' }
|
||||
: null
|
||||
},
|
||||
match: event => (event.type === 'user/message' && event.data.source.kind === 'user'
|
||||
? { id: `${event.seq}`, role: 'start' }
|
||||
: null),
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'turn/start') {
|
||||
throw new Error('perf-prompt start requires turn/start')
|
||||
if (match.event.type !== 'user/message') {
|
||||
throw new Error('perf-prompt start requires user/message')
|
||||
}
|
||||
const located = locationTurnStep(match.location)
|
||||
const prompt = promptOf(match.event.data)
|
||||
return {
|
||||
...newState(located?.turn ?? 0, PROMPT_STEP, 'prompt'),
|
||||
prompt: prompt.text,
|
||||
images: prompt.images,
|
||||
}
|
||||
/* v8 ignore next -- start only receives turn/start, which folding always handles. */
|
||||
return foldEvent(undefined, match.event) ?? newState(match.event.data.turn, PROMPT_STEP, 'prompt')
|
||||
},
|
||||
/* v8 ignore next -- the engine calls update only after start adopted a state, so folding never returns undefined here. */
|
||||
update: (context, match) => foldEvent(context.state, match.event) ?? context.state,
|
||||
publication: match => (match.event.type === 'turn/start' ? 'none' : 'immediate'),
|
||||
update: context => context.state,
|
||||
publication: () => 'immediate',
|
||||
buildViewNode: (context) => {
|
||||
const state = context.state
|
||||
if (state === undefined) return null
|
||||
const located = locationTurnStep(nodeLocation(context))
|
||||
if (state === undefined || located === null) return null
|
||||
return perfNode(context, context.start?.event.seq ?? 0, {
|
||||
kind: 'prompt',
|
||||
turn: state.turn,
|
||||
turn: located.turn,
|
||||
prompt: state.prompt,
|
||||
images: state.images,
|
||||
})
|
||||
@@ -151,14 +163,12 @@ const endDefinition: ConversationNodeDefinition<PerfStepState> = {
|
||||
if (match.event.type !== 'turn/end') {
|
||||
throw new Error('perf-turn-end start requires turn/end')
|
||||
}
|
||||
const reason = match.event.data.reason
|
||||
const closeKind = 'kind' in reason && typeof reason.kind === 'string' ? reason.kind : null
|
||||
return {
|
||||
...newState(match.event.data.turn, END_STEP, 'end'),
|
||||
ended: true,
|
||||
endTime: match.event.time,
|
||||
closed: true,
|
||||
closeKind,
|
||||
closeKind: match.event.data.reason.kind,
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
@@ -175,7 +185,51 @@ const endDefinition: ConversationNodeDefinition<PerfStepState> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the three Definitions. Each registration rides the caller's fiber
|
||||
* One Context per Code-Mode root call folds its inner dispatches; the builder
|
||||
* merges them into the owning step by the root call id. The events carry no
|
||||
* turn or step, so the Context takes both from the engine's match location.
|
||||
*/
|
||||
const codeModeDefinition: ConversationNodeDefinition<PerfStepState> = {
|
||||
kind: CODE_MODE_KIND,
|
||||
target: TARGET,
|
||||
match: (event) => {
|
||||
if (event.type !== 'tool/ptc-dispatch-start' && event.type !== 'tool/ptc-dispatch') return null
|
||||
return {
|
||||
id: `code:${event.data.rootCallId}`,
|
||||
role: event.type === 'tool/ptc-dispatch-start' ? 'start' : 'update',
|
||||
}
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'tool/ptc-dispatch-start') {
|
||||
throw new Error('perf-code-mode start requires tool/ptc-dispatch-start')
|
||||
}
|
||||
const located = locationTurnStep(match.location)
|
||||
const base = located?.step === null || located === null
|
||||
? newState(0, 0)
|
||||
: newState(located.turn, located.step)
|
||||
/* v8 ignore next -- folding a defined state always returns a state. */
|
||||
return foldEvent(base, match.event) ?? base
|
||||
},
|
||||
/* v8 ignore next -- the engine calls update only after start adopted a state, so folding never returns undefined here. */
|
||||
update: (context, match) => foldEvent(context.state, match.event) ?? context.state,
|
||||
buildViewNode: (context) => {
|
||||
const state = stepState(context)
|
||||
const located = locationTurnStep(nodeLocation(context))
|
||||
if (state === undefined || located?.step === null || located === null) return null
|
||||
const calls = stepMetrics(state).calls
|
||||
.filter(call => !isCodeMode(call.name))
|
||||
.slice(0, CALL_LIMIT)
|
||||
return perfNode(context, context.start?.event.seq ?? 0, {
|
||||
kind: 'inner',
|
||||
turn: located.turn,
|
||||
step: located.step,
|
||||
calls,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the four Definitions. Each registration rides the caller's fiber
|
||||
* effect, so unloading the plugin removes the nodes with it.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
@@ -183,4 +237,5 @@ export function registerPerfDefinitions(ctx: Context): void {
|
||||
ctx.uiConversation.events.register(stepDefinition)
|
||||
ctx.uiConversation.events.register(promptDefinition)
|
||||
ctx.uiConversation.events.register(endDefinition)
|
||||
ctx.uiConversation.events.register(codeModeDefinition)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
/** Event folding and per-step metric derivation for the performance table. */
|
||||
|
||||
import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, StreamChunk, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream'
|
||||
// Type-only: the PTC event members this fold reads.
|
||||
import type {} from '@deepseek-ai/dsh-tools/types'
|
||||
import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types'
|
||||
import type {
|
||||
PerfCall, PerfStepMetrics, PerfStepState, PerfToolCount, PerfUsage,
|
||||
PerfCall, PerfStepMetrics, PerfStepState, PerfToolCount,
|
||||
} from './perf-contract.ts'
|
||||
import {
|
||||
cacheHitRate, cacheReadTokens, cacheWriteTokens, contextTokens, isNumber, isUsage,
|
||||
cacheHitRate, cacheReadTokens, cacheWriteTokens, contextTokens, isNumber,
|
||||
outputTokens,
|
||||
} from './perf-usage.ts'
|
||||
|
||||
@@ -89,6 +89,11 @@ function countLines(text: string): number {
|
||||
return text.charCodeAt(text.length - 1) === 10 ? lines - 1 : lines
|
||||
}
|
||||
|
||||
/** Read a record entry only when the record owns it, so model ids never resolve inherited members. */
|
||||
function ownValue<T>(record: Readonly<Record<string, T>> | null, key: string): T | undefined {
|
||||
return record !== null && Object.hasOwn(record, key) ? record[key] : undefined
|
||||
}
|
||||
|
||||
/** Longest common subsequence length, one rolling row; fine below 400 lines a side. */
|
||||
function lcsLength(left: readonly string[], right: readonly string[]): number {
|
||||
const width = right.length + 1
|
||||
@@ -96,9 +101,9 @@ function lcsLength(left: readonly string[], right: readonly string[]): number {
|
||||
let current = new Uint16Array(width)
|
||||
for (let row = 1; row <= left.length; row += 1) {
|
||||
for (let column = 1; column <= right.length; column += 1) {
|
||||
/* v8 ignore next -- an in-bounds typed-array read is always a number; the fallback only satisfies the index type. */
|
||||
/* v8 ignore next -- an in-bounds typed-array read always yields a number; the fallback only satisfies the index type. */
|
||||
const up = previous[column] ?? 0
|
||||
/* v8 ignore next -- an in-bounds typed-array read is always a number; the fallback only satisfies the index type. */
|
||||
/* v8 ignore next -- an in-bounds typed-array read always yields a number; the fallback only satisfies the index type. */
|
||||
const leftCell = current[column - 1] ?? 0
|
||||
/* v8 ignore next 3 -- the typed-array nullish fallback is unreachable; in-bounds reads never yield undefined. */
|
||||
current[column] = left[row - 1] === right[column - 1]
|
||||
@@ -110,7 +115,7 @@ function lcsLength(left: readonly string[], right: readonly string[]): number {
|
||||
current = swap
|
||||
current.fill(0)
|
||||
}
|
||||
/* v8 ignore next -- an in-bounds typed-array read is always a number; the fallback only satisfies the index type. */
|
||||
/* v8 ignore next -- an in-bounds typed-array read always yields a number; the fallback only satisfies the index type. */
|
||||
return previous[right.length] ?? 0
|
||||
}
|
||||
|
||||
@@ -211,14 +216,24 @@ function toolHint(name: string, raw: unknown): string | null {
|
||||
}
|
||||
|
||||
/** Code-Mode steps call this one tool; the real tools run inside it. */
|
||||
function isCodeMode(name: string | null): boolean {
|
||||
export function isCodeMode(name: string | null): boolean {
|
||||
return name !== null && /^run_?code$/i.test(name)
|
||||
}
|
||||
|
||||
/** Tool name of one call; a nameless call counts but invents no name. */
|
||||
function calledTool(data: { readonly name?: unknown }): string | null {
|
||||
const name = data.name
|
||||
return typeof name === 'string' && name !== '' ? name : null
|
||||
/** Arguments-derived PerfCall fields shared by a native call and one Code-Mode dispatch. */
|
||||
function callArguments(
|
||||
name: string,
|
||||
raw: unknown,
|
||||
): Pick<PerfCall, 'hint' | 'added' | 'removed' | 'before' | 'after' | 'args'> {
|
||||
const delta = editDelta(name, raw)
|
||||
return {
|
||||
hint: toolHint(name, raw),
|
||||
added: delta === null ? null : delta.added,
|
||||
removed: delta === null ? null : delta.removed,
|
||||
before: delta === null ? null : delta.before,
|
||||
after: delta === null ? null : delta.after,
|
||||
args: rawArgs(raw),
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether one stream chunk counts as the first model token. */
|
||||
@@ -234,49 +249,6 @@ function isTokenDelta(chunk: StreamChunk): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Root call id → step id, for inner Code-Mode calls that carry no turn/step. */
|
||||
const PTC_STEPS = new Map<string, string>()
|
||||
const PTC_STEPS_MAX = 4_000
|
||||
|
||||
/** Remember which step owns one Code-Mode root call, evicting the oldest entry. */
|
||||
function rememberPtcCall(callId: string, stepId: string): void {
|
||||
PTC_STEPS.set(callId, stepId)
|
||||
if (PTC_STEPS.size <= PTC_STEPS_MAX) return
|
||||
const oldest = PTC_STEPS.keys().next().value
|
||||
/* v8 ignore next -- the size check above guarantees a first key, so the guard never sees undefined. */
|
||||
if (oldest !== undefined) PTC_STEPS.delete(oldest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Step id owning one Code-Mode root call.
|
||||
* @param rootCallId - root call id carried by a dispatch event.
|
||||
* @returns the owning step id, or null when the call was not recorded.
|
||||
*/
|
||||
export function ptcStepOf(rootCallId: unknown): string | null {
|
||||
return typeof rootCallId === 'string' ? PTC_STEPS.get(rootCallId) ?? null : null
|
||||
}
|
||||
|
||||
/** Clear the Code-Mode owner table; tests use it to isolate cases. */
|
||||
export function resetPtcSteps(): void {
|
||||
PTC_STEPS.clear()
|
||||
}
|
||||
|
||||
/** Current turn number; `user/message` carries no turn and is attributed by order. */
|
||||
let currentTurn: number | null = null
|
||||
|
||||
/**
|
||||
* Turn number of the latest `turn/start`.
|
||||
* @returns the current turn, or null before the window has one.
|
||||
*/
|
||||
export function currentTurnOf(): number | null {
|
||||
return currentTurn
|
||||
}
|
||||
|
||||
/** Reset the current-turn marker; tests use it to isolate cases. */
|
||||
export function resetCurrentTurn(): void {
|
||||
currentTurn = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial state of one step or one turn-level marker.
|
||||
* @param turn - turn number.
|
||||
@@ -318,38 +290,23 @@ export function newState(
|
||||
}
|
||||
}
|
||||
|
||||
/** Call id of a tool result lives on the message source, not on the result event. */
|
||||
function resultCallId(message: unknown): string | null {
|
||||
if (message === null || typeof message !== 'object') return null
|
||||
const source = (message as { source?: unknown }).source
|
||||
if (source === null || typeof source !== 'object') return null
|
||||
const callId = (source as { callId?: unknown }).callId
|
||||
return typeof callId === 'string' ? callId : null
|
||||
}
|
||||
|
||||
/** Result text: the text blocks of the tool message joined by newlines. */
|
||||
function resultText(message: unknown): string | null {
|
||||
/* v8 ignore next -- callers reach this helper only after resolving a call id from an object message. */
|
||||
if (message === null || typeof message !== 'object') return null
|
||||
const content = (message as { content?: unknown }).content
|
||||
if (!Array.isArray(content)) return null
|
||||
function resultText(content: readonly ContentBlock[]): string | null {
|
||||
const parts: string[] = []
|
||||
for (const item of content as unknown[]) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const block = item as { type?: unknown; text?: unknown }
|
||||
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
||||
for (const block of content) {
|
||||
if (block.type === 'text') parts.push(block.text)
|
||||
}
|
||||
return parts.length === 0 ? null : parts.join('\n')
|
||||
}
|
||||
|
||||
/** Size, line count, and bounded body of one tool result. */
|
||||
function resultInfo(message: unknown): {
|
||||
function resultInfo(content: readonly ContentBlock[]): {
|
||||
chars: number
|
||||
lines: number
|
||||
text: string | null
|
||||
truncated: boolean
|
||||
} {
|
||||
const text = resultText(message)
|
||||
const text = resultText(content)
|
||||
if (text === null) return { chars: 0, lines: 0, text: null, truncated: false }
|
||||
let lines = 0
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
@@ -360,28 +317,12 @@ function resultInfo(message: unknown): {
|
||||
return { chars: text.length, lines, text: body.text, truncated: body.truncated }
|
||||
}
|
||||
|
||||
/** Whether one tool result reports an error. */
|
||||
function resultFailed(message: unknown): boolean {
|
||||
return message !== null && typeof message === 'object'
|
||||
&& (message as { isError?: unknown }).isError === true
|
||||
}
|
||||
|
||||
/**
|
||||
* User question: `user/message` also carries injected runtime context and time
|
||||
* snapshots, so only `source.kind === 'user'` is the text the user typed.
|
||||
*/
|
||||
function promptOf(data: {
|
||||
readonly content?: unknown
|
||||
readonly source?: unknown
|
||||
}): { text: string | null; images: number } {
|
||||
const content = data.content
|
||||
if (!Array.isArray(content)) return { text: null, images: 0 }
|
||||
/** User question: only a human-typed `user` source is the text the user wrote. */
|
||||
export function promptOf(message: UserMessage): { text: string | null; images: number } {
|
||||
const parts: string[] = []
|
||||
let images = 0
|
||||
for (const item of content as unknown[]) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const block = item as { type?: unknown; text?: unknown }
|
||||
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
||||
for (const block of message.content) {
|
||||
if (block.type === 'text') parts.push(block.text)
|
||||
if (block.type === 'image') images += 1
|
||||
}
|
||||
return { text: clip(parts.join(' '), 400), images }
|
||||
@@ -410,28 +351,11 @@ export function foldEvent(
|
||||
case 'step/start':
|
||||
return { ...newState(event.data.turn, event.data.step), startSeq: event.seq, startTime: event.time }
|
||||
|
||||
case 'turn/start': {
|
||||
currentTurn = event.data.turn
|
||||
return newState(event.data.turn, 0, 'prompt')
|
||||
}
|
||||
|
||||
case 'user/message': {
|
||||
const next = state ?? newState(currentTurn ?? 0, 0)
|
||||
const base: PerfStepState = { ...next, mark: 'prompt' }
|
||||
const source: unknown = event.data.source
|
||||
if (typeof source !== 'object' || source === null
|
||||
|| (source as { kind?: unknown }).kind !== 'user') return base
|
||||
if (base.prompt !== null) return base
|
||||
const prompt = promptOf(event.data)
|
||||
return { ...base, prompt: prompt.text, images: prompt.images }
|
||||
}
|
||||
|
||||
case 'assistant/live-chunk': {
|
||||
const next = state ?? newState(event.data.turn, event.data.step)
|
||||
const chunk = event.data.chunk
|
||||
if (chunk.type === 'usage') {
|
||||
const usage: PerfUsage = chunk.usage
|
||||
return isUsage(usage) ? { ...next, usage } : next
|
||||
return { ...next, usage: chunk.usage }
|
||||
}
|
||||
if (next.firstTokenTime === null && isTokenDelta(chunk)) {
|
||||
return { ...next, firstTokenTime: event.time }
|
||||
@@ -447,11 +371,10 @@ export function foldEvent(
|
||||
if (isNumber(fromStream)) next = { ...next, firstTokenTime: fromStream }
|
||||
}
|
||||
if (event.type === 'assistant/attempt') return next
|
||||
const usage = event.data.usage
|
||||
return {
|
||||
...next,
|
||||
messageTime: next.messageTime ?? event.time,
|
||||
usage: isUsage(usage) ? usage : next.usage,
|
||||
usage: event.data.usage ?? next.usage,
|
||||
interrupted: event.data.interrupted === true,
|
||||
}
|
||||
}
|
||||
@@ -462,35 +385,30 @@ export function foldEvent(
|
||||
const pending: Record<string, number> = { ...(next.pending ?? {}) }
|
||||
const known = Object.hasOwn(pending, callId)
|
||||
pending[callId] = event.time
|
||||
const name = calledTool(event.data)
|
||||
const name = event.data.name
|
||||
const calls: Record<string, PerfCall> = { ...(next.calls ?? {}) }
|
||||
if (isCodeMode(name)) rememberPtcCall(callId, `${event.data.turn}:${event.data.step}`)
|
||||
if (!Object.hasOwn(calls, callId)) {
|
||||
const delta = name === null ? null : editDelta(name, event.data.arguments)
|
||||
calls[callId] = {
|
||||
callId,
|
||||
seq: event.seq,
|
||||
name,
|
||||
hint: name === null ? null : toolHint(name, event.data.arguments),
|
||||
...callArguments(name, event.data.arguments),
|
||||
chars: null,
|
||||
lines: null,
|
||||
error: false,
|
||||
added: delta === null ? null : delta.added,
|
||||
removed: delta === null ? null : delta.removed,
|
||||
before: delta === null ? null : delta.before,
|
||||
after: delta === null ? null : delta.after,
|
||||
args: rawArgs(event.data.arguments),
|
||||
inner: false,
|
||||
text: null,
|
||||
truncated: false,
|
||||
ms: null,
|
||||
startedAt: event.time,
|
||||
endedAt: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
pending,
|
||||
toolOpen: known ? next.toolOpen : next.toolOpen + 1,
|
||||
tools: name === null ? next.tools : bumpTool(next.tools, name),
|
||||
tools: bumpTool(next.tools, name),
|
||||
calls,
|
||||
}
|
||||
}
|
||||
@@ -500,10 +418,8 @@ export function foldEvent(
|
||||
|
||||
/**
|
||||
* Inner Code-Mode call: the name and arguments are the real tool's, the
|
||||
* content is the real result. It does not count toward tool time (the outer
|
||||
* `run_code` call already covers it), but its own dispatch span is measured.
|
||||
* The event carries no turn or step, so it only folds into a context the
|
||||
* root-call map already resolved.
|
||||
* content is the real result. It does not count toward the outer call's
|
||||
* tool time; its own dispatch span is measured instead.
|
||||
*/
|
||||
case 'tool/ptc-dispatch-start': {
|
||||
if (state === undefined) return state
|
||||
@@ -514,30 +430,25 @@ export function foldEvent(
|
||||
case 'tool/ptc-dispatch': {
|
||||
const next = state
|
||||
if (next === undefined) return next
|
||||
const name = calledTool(event.data)
|
||||
if (name === null) return next
|
||||
const info = resultInfo({ content: event.data.content })
|
||||
const delta = editDelta(name, event.data.arguments)
|
||||
const name = event.data.name
|
||||
const info = resultInfo(event.data.content)
|
||||
const callKey = event.data.subCallId
|
||||
const startedAt = next.starts?.[callKey]
|
||||
const startedAt = ownValue(next.starts, callKey)
|
||||
const calls: Record<string, PerfCall> = { ...(next.calls ?? {}) }
|
||||
calls[callKey] = {
|
||||
callId: callKey,
|
||||
seq: event.seq,
|
||||
name,
|
||||
hint: toolHint(name, event.data.arguments),
|
||||
...callArguments(name, event.data.arguments),
|
||||
chars: info.chars,
|
||||
lines: info.lines,
|
||||
text: info.text,
|
||||
truncated: info.truncated,
|
||||
error: event.data.isError,
|
||||
added: delta === null ? null : delta.added,
|
||||
removed: delta === null ? null : delta.removed,
|
||||
before: delta === null ? null : delta.before,
|
||||
after: delta === null ? null : delta.after,
|
||||
args: rawArgs(event.data.arguments),
|
||||
inner: true,
|
||||
ms: isNumber(startedAt) ? Math.max(0, event.time - startedAt) : null,
|
||||
startedAt: isNumber(startedAt) ? startedAt : null,
|
||||
endedAt: event.time,
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
@@ -550,26 +461,27 @@ export function foldEvent(
|
||||
case 'tool/result': {
|
||||
const next = state ?? newState(event.data.turn, event.data.step)
|
||||
const message = event.data.message
|
||||
const callId = resultCallId(message)
|
||||
const failed = resultFailed(message)
|
||||
const before = callId === null ? undefined : next.calls?.[callId]
|
||||
const callId: string = message.source.callId
|
||||
const failed = message.isError === true
|
||||
const before = ownValue(next.calls, callId)
|
||||
const calls: Record<string, PerfCall> = { ...(next.calls ?? {}) }
|
||||
if (callId !== null && before !== undefined) {
|
||||
if (before !== undefined) {
|
||||
calls[callId] = {
|
||||
...before,
|
||||
...resultInfo(message),
|
||||
...resultInfo(message.content),
|
||||
error: failed || before.error,
|
||||
/* v8 ignore next -- a native call record always carries the start time tool/call recorded. */
|
||||
ms: isNumber(before.startedAt) ? Math.max(0, event.time - before.startedAt) : before.ms,
|
||||
endedAt: event.time,
|
||||
}
|
||||
}
|
||||
const withError = failed ? { ...next, toolErrors: next.toolErrors + 1 } : next
|
||||
// Own-property check: the call id is model generated, so a name such as
|
||||
// `constructor` must not resolve to an inherited function as a start time.
|
||||
const dispatched = callId === null ? undefined : next.pending?.[callId]
|
||||
const dispatched = ownValue(next.pending, callId)
|
||||
if (!isNumber(dispatched)) {
|
||||
return { ...withError, calls, toolMissing: next.toolMissing + 1 }
|
||||
}
|
||||
const pending: Record<string, number> = {}
|
||||
/* v8 ignore next -- a numeric dispatched time implies pending exists, so the empty fallback is unreachable. */
|
||||
/* v8 ignore next -- a numeric pending start implies pending exists; the empty fallback is unreachable. */
|
||||
for (const [key, value] of Object.entries(next.pending ?? {})) {
|
||||
if (key !== callId) pending[key] = value
|
||||
}
|
||||
@@ -609,17 +521,6 @@ function callList(calls: Readonly<Record<string, PerfCall>> | null): PerfCall[]
|
||||
})
|
||||
}
|
||||
|
||||
/** Tool result characters one step pushed into the next prompt. */
|
||||
function outputChars(calls: Readonly<Record<string, PerfCall>> | null, skipRunner: boolean): number {
|
||||
if (calls === null) return 0
|
||||
let total = 0
|
||||
for (const call of Object.values(calls)) {
|
||||
if (skipRunner && isCodeMode(call.name) && !call.inner) continue
|
||||
if (isNumber(call.chars)) total += call.chars
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** Tool tallies by descending count; ties keep first-call order. */
|
||||
function sortedTools(tools: Readonly<Record<string, number>> | null): PerfToolCount[] {
|
||||
if (tools === null) return []
|
||||
@@ -628,6 +529,37 @@ function sortedTools(tools: Readonly<Record<string, number>> | null): PerfToolCo
|
||||
.sort((left, right) => right.count - left.count)
|
||||
}
|
||||
|
||||
/** Wall span covered by calls that both started and settled; overlaps count once. */
|
||||
export function toolSpanUnion(calls: readonly PerfCall[]): number | null {
|
||||
const intervals = calls
|
||||
.filter((call): call is PerfCall & { startedAt: number; endedAt: number } =>
|
||||
isNumber(call.startedAt) && isNumber(call.endedAt))
|
||||
.map(call => [call.startedAt, call.endedAt] as const)
|
||||
.sort((left, right) => left[0] - right[0])
|
||||
if (intervals.length === 0) return null
|
||||
let total = 0
|
||||
let [start, end] = intervals[0] as readonly [number, number]
|
||||
for (const [nextStart, nextEnd] of intervals.slice(1)) {
|
||||
if (nextStart > end) {
|
||||
total += end - start
|
||||
start = nextStart
|
||||
end = nextEnd
|
||||
} else if (nextEnd > end) {
|
||||
end = nextEnd
|
||||
}
|
||||
}
|
||||
return total + end - start
|
||||
}
|
||||
|
||||
/** Tool result characters one step pushed into the next prompt. */
|
||||
function outputChars(calls: readonly PerfCall[]): number {
|
||||
let total = 0
|
||||
for (const call of calls) {
|
||||
if (isNumber(call.chars)) total += call.chars
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Measured metrics of one step.
|
||||
* @param state - folded step state.
|
||||
@@ -646,10 +578,9 @@ export function stepMetrics(state: PerfStepState): PerfStepMetrics {
|
||||
? output / (decode / 1000)
|
||||
: null
|
||||
const settled = isNumber(message)
|
||||
const rawCalls = callList(state.calls)
|
||||
const inner = rawCalls.some(call => call.inner)
|
||||
const calls = inner ? rawCalls.filter(call => !isCodeMode(call.name)) : rawCalls
|
||||
const tools = sortedTools(state.tools).filter(item => !(inner && isCodeMode(item.name)))
|
||||
const calls = callList(state.calls)
|
||||
// The union spans every paired call, not just the row's displayed prefix.
|
||||
const allCalls = state.calls === null ? [] : Object.values(state.calls)
|
||||
return {
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
@@ -662,9 +593,10 @@ export function stepMetrics(state: PerfStepState): PerfStepMetrics {
|
||||
tps,
|
||||
output,
|
||||
tool: state.toolPairs > 0 ? state.toolMs : null,
|
||||
tools,
|
||||
toolWall: toolSpanUnion(allCalls),
|
||||
tools: sortedTools(state.tools),
|
||||
calls,
|
||||
toolOutput: outputChars(state.calls, inner),
|
||||
toolOutput: outputChars(calls),
|
||||
retries: state.retries,
|
||||
toolErrors: state.toolErrors,
|
||||
cacheRead: cacheReadTokens(state.usage),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,381 +1,527 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { AssistantStreamRecord, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionEventType } from '@deepseek-ai/dsh-session/types'
|
||||
import { aggregateTurn, buildTurns } from '../src/client/perf-aggregate.ts'
|
||||
import {
|
||||
currentTurnOf, foldEvent, newState, resetCurrentTurn, resetPtcSteps, stepMetrics,
|
||||
} from '../src/client/perf-fold.ts'
|
||||
import {
|
||||
DASH, barShare, formatChars, formatClock, formatDateTime, formatMs, formatPercent,
|
||||
formatTokens, formatTokensDelta, formatTokensShort, formatTps,
|
||||
} from '../src/client/perf-format.ts'
|
||||
import type { PerfStepMetrics, PerfStepState, PerfViewNode } from '../src/client/perf-contract.ts'
|
||||
import {
|
||||
cacheHitRate, contextTokens, isCount, isUsage, outputTokens,
|
||||
} from '../src/client/perf-usage.ts'
|
||||
import type { PerfCall, PerfStepMetrics, PerfViewNode } from '../src/client/perf-contract.ts'
|
||||
import { CALL_LIMIT } from '../src/client/perf-fold.ts'
|
||||
|
||||
let seq = 0
|
||||
|
||||
/** One durable session event with a deterministic sequence and time. */
|
||||
function event(type: SessionEventType, data: object, time?: number): SessionEventLike {
|
||||
const at = time ?? 1_000 + (seq += 1) * 10
|
||||
return { type, seq: SessionSeq(seq), time: at, data } as SessionEventLike
|
||||
}
|
||||
|
||||
/** Narrow a folded state the fixture guarantees exists. */
|
||||
function defined<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error('expected a folded state')
|
||||
return value
|
||||
}
|
||||
|
||||
/** One client-only live chunk event. */
|
||||
function chunk(turn: number, step: number, value: StreamChunk, time?: number): SessionEventLike {
|
||||
seq += 1
|
||||
/** One step row with every unmeasured column blank. */
|
||||
function metricsOf(overrides: Partial<PerfStepMetrics> = {}): PerfStepMetrics {
|
||||
return {
|
||||
type: 'assistant/live-chunk',
|
||||
seq,
|
||||
time: time ?? 1_000 + seq * 10,
|
||||
data: { attemptId: 'attempt-1', turn, step, chunk: value },
|
||||
} as SessionEventLike
|
||||
turn: 1,
|
||||
step: 1,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
context: null,
|
||||
ttft: null,
|
||||
model: null,
|
||||
decode: null,
|
||||
tps: null,
|
||||
output: null,
|
||||
tool: null,
|
||||
toolWall: null,
|
||||
tools: [],
|
||||
calls: [],
|
||||
toolOutput: 0,
|
||||
retries: 0,
|
||||
toolErrors: 0,
|
||||
cacheRead: null,
|
||||
cacheWrite: null,
|
||||
cacheHit: null,
|
||||
wall: null,
|
||||
toolPending: 0,
|
||||
running: false,
|
||||
interrupted: false,
|
||||
aborted: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A token delta chunk. */
|
||||
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,
|
||||
function perfCall(callId: string, seqAt: number, overrides: Partial<PerfCall> = {}): PerfCall {
|
||||
return {
|
||||
callId,
|
||||
seq: seqAt,
|
||||
name: 'read',
|
||||
hint: null,
|
||||
chars: null,
|
||||
lines: null,
|
||||
error: false,
|
||||
added: null,
|
||||
removed: null,
|
||||
before: null,
|
||||
after: null,
|
||||
args: null,
|
||||
inner: false,
|
||||
text: null,
|
||||
truncated: false,
|
||||
ms: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A compact assistant stream recording the first token at `time`. */
|
||||
function stream(firstTokenAt: number): AssistantStreamRecord[] {
|
||||
return [
|
||||
{ type: 'chunk', time: firstTokenAt - 100, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
|
||||
{ type: 'chunk', time: firstTokenAt, chunk: delta },
|
||||
] as AssistantStreamRecord[]
|
||||
function stepNode(value: PerfStepMetrics): PerfViewNode {
|
||||
return {
|
||||
key: `${value.turn}:${value.step}`,
|
||||
kind: 'perf-step',
|
||||
id: `${value.turn}:${value.step}`,
|
||||
target: 'perf',
|
||||
anchorSeq: value.startTime ?? 0,
|
||||
location: { kind: 'unresolved' },
|
||||
data: { kind: 'step', metrics: value },
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a step state across a start, a stream, a message, one tool call, and an end. */
|
||||
function completedStep(turn: number, step: number, startTime = 1_000): ReturnType<typeof stepMetrics> {
|
||||
resetPtcSteps()
|
||||
resetCurrentTurn()
|
||||
let state = foldEvent(undefined, event('step/start', { turn, step }, startTime))
|
||||
state = foldEvent(state, chunk(turn, step, delta, startTime + 100))
|
||||
state = foldEvent(state, event('assistant/message', {
|
||||
turn,
|
||||
step,
|
||||
message: { id: 'm', role: 'assistant', content: [], source: { provider: 'p', model: 'm' } },
|
||||
stream: stream(startTime + 300),
|
||||
usage,
|
||||
}, startTime + 400))
|
||||
state = foldEvent(state, event('tool/call', {
|
||||
turn, step, callId: 'call-1', name: 'read', arguments: JSON.stringify({ file_path: '/a/b/c.ts' }),
|
||||
}, startTime + 500))
|
||||
state = foldEvent(state, event('tool/result', {
|
||||
turn,
|
||||
step,
|
||||
message: { role: 'tool', content: [{ type: 'text', text: 'one\ntwo' }], source: { callId: 'call-1' } },
|
||||
}, startTime + 700))
|
||||
state = foldEvent(state, event('step/end', { turn, step }, startTime + 800))
|
||||
return stepMetrics(defined(state))
|
||||
function promptNode(turn: number, prompt: string | null, images = 0): PerfViewNode {
|
||||
return {
|
||||
key: `${turn}:prompt:${prompt ?? 'null'}`,
|
||||
kind: 'perf-prompt',
|
||||
id: `${turn}:prompt`,
|
||||
target: 'perf',
|
||||
anchorSeq: 1,
|
||||
location: { kind: 'unresolved' },
|
||||
data: { kind: 'prompt', turn, prompt, images },
|
||||
}
|
||||
}
|
||||
|
||||
describe('format helpers', () => {
|
||||
it('formats durations at each scale boundary', () => {
|
||||
expect(formatMs(null)).toBe(DASH)
|
||||
expect(formatMs(999)).toBe('999 ms')
|
||||
expect(formatMs(1_000)).toBe('1.00 s')
|
||||
expect(formatMs(59_999)).toBe('60.00 s')
|
||||
expect(formatMs(60_000)).toBe('1.0 min')
|
||||
})
|
||||
function endNode(turn: number, endTime: number | null, closeKind: string | null): PerfViewNode {
|
||||
return {
|
||||
key: `${turn}:end`,
|
||||
kind: 'perf-turn-end',
|
||||
id: `${turn}:end`,
|
||||
target: 'perf',
|
||||
anchorSeq: 2,
|
||||
location: { kind: 'unresolved' },
|
||||
data: { kind: 'end', turn, closeKind, endTime },
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps two decimals below one thousand tokens', () => {
|
||||
expect(formatTokensShort(null)).toBe(DASH)
|
||||
expect(formatTokensShort(30)).toBe('0.03K')
|
||||
expect(formatTokensShort(1_500)).toBe('1.5K')
|
||||
expect(formatTokensDelta(250)).toBe('+0.25K')
|
||||
expect(formatTokensDelta(-730)).toBe('−0.73K')
|
||||
expect(formatTokensDelta(null)).toBe('')
|
||||
expect(formatTokens(1_234_567)).toBe('1,234,567')
|
||||
})
|
||||
|
||||
it('formats characters, speed, and percent', () => {
|
||||
expect(formatChars(null)).toBe(DASH)
|
||||
expect(formatChars(0)).toBe(DASH)
|
||||
expect(formatChars(12)).toBe('12')
|
||||
expect(formatChars(1_234)).toBe('1.2K')
|
||||
expect(formatTps(268.94)).toBe('268.9')
|
||||
expect(formatTps(null)).toBe(DASH)
|
||||
expect(formatPercent(0.962)).toBe('96.20%')
|
||||
expect(formatPercent(null)).toBe(DASH)
|
||||
})
|
||||
|
||||
it('formats the clock from the same instant it is given', () => {
|
||||
const time = new Date(2026, 9, 20, 3, 4, 5).getTime()
|
||||
expect(formatClock(time)).toBe('03:04:05')
|
||||
expect(formatDateTime(time)).toBe('2026-10-20 03:04:05')
|
||||
expect(formatClock(null)).toBe(DASH)
|
||||
expect(formatDateTime(null)).toBe(DASH)
|
||||
})
|
||||
|
||||
it('scales bars by square root of the group maximum', () => {
|
||||
expect(barShare(10, 0)).toBe(1)
|
||||
expect(barShare(0, 100)).toBe(0)
|
||||
expect(barShare(100, 100)).toBe(1)
|
||||
expect(barShare(25, 100)).toBe(0.5)
|
||||
expect(barShare(200, 100)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usage helpers', () => {
|
||||
it('sums the prompt length and derives cache hit', () => {
|
||||
expect(isUsage(null)).toBe(false)
|
||||
expect(isUsage(usage)).toBe(true)
|
||||
expect(isCount(-1)).toBe(false)
|
||||
expect(contextTokens(usage)).toBe(600)
|
||||
expect(contextTokens(null)).toBeNull()
|
||||
expect(outputTokens(usage)).toBe(40)
|
||||
expect(cacheHitRate(usage)).toBeCloseTo(400 / 600)
|
||||
expect(cacheHitRate({})).toBeNull()
|
||||
expect(cacheHitRate({ inputTokens: 0 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('step folding and metrics', () => {
|
||||
it('derives timeline, throughput, tool cost, and cache columns', () => {
|
||||
const metrics = completedStep(1, 1)
|
||||
expect(metrics.startTime).toBe(1_000)
|
||||
expect(metrics.endTime).toBe(1_800)
|
||||
expect(metrics.ttft).toBe(100)
|
||||
expect(metrics.model).toBe(400)
|
||||
expect(metrics.decode).toBe(300)
|
||||
expect(metrics.tps).toBeCloseTo(40 / 0.3)
|
||||
expect(metrics.tool).toBe(200)
|
||||
expect(metrics.toolPending).toBe(0)
|
||||
expect(metrics.wall).toBe(800)
|
||||
expect(metrics.context).toBe(600)
|
||||
expect(metrics.output).toBe(40)
|
||||
expect(metrics.cacheHit).toBeCloseTo(400 / 600)
|
||||
expect(metrics.running).toBe(false)
|
||||
expect(metrics.aborted).toBe(false)
|
||||
expect(metrics.tools).toEqual([{ name: 'read', count: 1 }])
|
||||
expect(metrics.toolOutput).toBe(7)
|
||||
expect(metrics.calls[0]?.hint).toBe('b/c.ts')
|
||||
})
|
||||
|
||||
it('keeps a running step measurable up to the last event', () => {
|
||||
resetPtcSteps()
|
||||
const start = foldEvent(undefined, event('step/start', { turn: 2, step: 1 }, 5_000))
|
||||
const metrics = stepMetrics(defined(start))
|
||||
expect(metrics.running).toBe(true)
|
||||
expect(metrics.ttft).toBeNull()
|
||||
expect(metrics.wall).toBeNull()
|
||||
expect(metrics.context).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an unsettled step as aborted', () => {
|
||||
resetPtcSteps()
|
||||
let state = foldEvent(undefined, event('step/start', { turn: 2, step: 1 }, 5_000))
|
||||
state = foldEvent(state, chunk(2, 1, delta, 5_100))
|
||||
state = foldEvent(state, event('step/end', { turn: 2, step: 1 }, 5_200))
|
||||
expect(stepMetrics(defined(state)).aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('counts retries, tool errors, and unpaired results', () => {
|
||||
resetPtcSteps()
|
||||
let state: PerfStepState | undefined = newState(3, 1)
|
||||
state = foldEvent(state, event('llm/retry', {
|
||||
turn: 3, step: 1, failure: { message: 'rate limited' }, retry: 1, delayMs: 10, mode: 'normal', maxRetries: 3,
|
||||
}))
|
||||
state = foldEvent(state, event('tool/result', {
|
||||
turn: 3, step: 1, message: { role: 'tool', content: [], isError: true, source: { callId: 'ghost' } },
|
||||
}))
|
||||
const metrics = stepMetrics(defined(state))
|
||||
expect(metrics.retries).toBe(1)
|
||||
expect(metrics.toolErrors).toBe(1)
|
||||
expect(metrics.toolPending).toBe(1)
|
||||
})
|
||||
|
||||
it('groups Code-Mode inner calls and hides the runner', () => {
|
||||
resetPtcSteps()
|
||||
let state = foldEvent(undefined, event('step/start', { turn: 4, step: 1 }, 7_000))
|
||||
state = foldEvent(state, event('tool/call', {
|
||||
turn: 4, step: 1, callId: 'run-1', name: 'run_code', arguments: '{}',
|
||||
}, 7_010))
|
||||
state = foldEvent(state, event('tool/ptc-dispatch-start', {
|
||||
rootCallId: 'run-1', parentCallId: 'run-1', subCallId: 'sub-1', name: 'grep', arguments: '{"pattern":"x"}',
|
||||
}, 7_020))
|
||||
state = foldEvent(state, event('tool/ptc-dispatch', {
|
||||
rootCallId: 'run-1', parentCallId: 'run-1', subCallId: 'sub-1', name: 'grep',
|
||||
arguments: '{"pattern":"x"}', isError: false, content: [{ type: 'text', text: 'hit' }],
|
||||
}, 7_030))
|
||||
const metrics = stepMetrics(defined(state))
|
||||
expect(metrics.tools).toEqual([{ name: 'grep', count: 1 }])
|
||||
expect(metrics.calls).toHaveLength(1)
|
||||
expect(metrics.calls[0]?.inner).toBe(true)
|
||||
expect(metrics.calls[0]?.ms).toBe(10)
|
||||
expect(metrics.toolOutput).toBe(3)
|
||||
})
|
||||
|
||||
it('attributes inner calls through the root call map', () => {
|
||||
resetPtcSteps()
|
||||
let state = foldEvent(undefined, event('step/start', { turn: 5, step: 2 }, 7_000))
|
||||
state = foldEvent(state, event('tool/call', { turn: 5, step: 2, callId: 'root', name: 'run_code', arguments: '{}' }))
|
||||
expect(currentTurnOf()).toBeNull()
|
||||
state = foldEvent(state, event('tool/ptc-dispatch', {
|
||||
rootCallId: 'root', parentCallId: 'root', subCallId: 's', name: 'read',
|
||||
arguments: '{"file_path":"/x/y.js"}', isError: true, content: [],
|
||||
}))
|
||||
const metrics = stepMetrics(defined(state))
|
||||
expect(metrics.turn).toBe(5)
|
||||
expect(metrics.step).toBe(2)
|
||||
expect(metrics.toolErrors).toBe(1)
|
||||
})
|
||||
|
||||
it('records the user question only from user-sourced messages', () => {
|
||||
resetPtcSteps()
|
||||
resetCurrentTurn()
|
||||
let state = foldEvent(undefined, event('turn/start', { turn: 6 }))
|
||||
expect(currentTurnOf()).toBe(6)
|
||||
state = foldEvent(state, event('user/message', {
|
||||
role: 'user',
|
||||
id: 'u1',
|
||||
content: [{ type: 'text', text: '帮我看看' }, { type: 'image' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
expect(state!.prompt).toBe('帮我看看')
|
||||
expect(state!.images).toBe(1)
|
||||
// A second question never overwrites the first.
|
||||
state = foldEvent(state, event('user/message', {
|
||||
role: 'user', id: 'u2', content: [{ type: 'text', text: '再问一次' }], source: { kind: 'user' },
|
||||
}))
|
||||
expect(state!.prompt).toBe('帮我看看')
|
||||
// Injected context is not a question.
|
||||
state = foldEvent(state, event('user/message', {
|
||||
role: 'user', id: 'u3', content: [{ type: 'text', text: '<system-reminder>' }], source: { kind: 'runtime-context' },
|
||||
}))
|
||||
expect(state!.prompt).toBe('帮我看看')
|
||||
})
|
||||
})
|
||||
function innerNode(
|
||||
turn: number,
|
||||
step: number,
|
||||
calls: readonly PerfCall[],
|
||||
): PerfViewNode {
|
||||
return {
|
||||
key: `${turn}:${step}:code:${calls.map(call => call.callId).join(',')}`,
|
||||
kind: 'perf-code-mode',
|
||||
id: `${turn}:${step}:code`,
|
||||
target: 'perf',
|
||||
anchorSeq: 3,
|
||||
location: { kind: 'unresolved' },
|
||||
data: { kind: 'inner', turn, step, calls },
|
||||
}
|
||||
}
|
||||
|
||||
describe('turn aggregation', () => {
|
||||
it('sums first-token waits and reports partial coverage', () => {
|
||||
const one = completedStep(7, 1, 10_000)
|
||||
const two = completedStep(7, 2, 11_000)
|
||||
const partial: PerfStepMetrics = { ...one, ttft: null }
|
||||
const turn = aggregateTurn(7, [one, two])
|
||||
expect(turn.ttft).toBe(200)
|
||||
it('reports an empty turn without invented values', () => {
|
||||
const turn = aggregateTurn(9, [])
|
||||
expect(turn.turn).toBe(9)
|
||||
expect(turn.steps).toEqual([])
|
||||
expect(turn.startTime).toBeNull()
|
||||
expect(turn.context).toBeNull()
|
||||
expect(turn.ttft).toBeNull()
|
||||
expect(turn.ttftPending).toBe(0)
|
||||
expect(turn.model).toBe(800)
|
||||
expect(turn.tool).toBe(400)
|
||||
expect(turn.wall).toBe(two.endTime! - one.startTime!)
|
||||
expect(turn.context).toBe(600)
|
||||
const lower = aggregateTurn(7, [one, partial])
|
||||
expect(lower.ttft).toBe(100)
|
||||
expect(lower.ttftPending).toBe(1)
|
||||
expect(aggregateTurn(7, [{ ...one, ttft: null }]).ttft).toBeNull()
|
||||
expect(turn.model).toBeNull()
|
||||
expect(turn.modelPending).toBe(0)
|
||||
expect(turn.decode).toBeNull()
|
||||
expect(turn.tps).toBeNull()
|
||||
expect(turn.output).toBeNull()
|
||||
expect(turn.tool).toBeNull()
|
||||
expect(turn.toolWall).toBeNull()
|
||||
expect(turn.toolPending).toBe(0)
|
||||
expect(turn.tools).toEqual([])
|
||||
expect(turn.cacheHit).toBeNull()
|
||||
expect(turn.cacheRead).toBeNull()
|
||||
expect(turn.cacheWrite).toBeNull()
|
||||
expect(turn.retries).toBe(0)
|
||||
expect(turn.toolErrors).toBe(0)
|
||||
expect(turn.toolOutput).toBe(0)
|
||||
expect(turn.lastStepWall).toBeNull()
|
||||
expect(turn.running).toBe(false)
|
||||
expect(turn.interrupted).toBe(false)
|
||||
expect(turn.aborted).toBe(false)
|
||||
expect(turn.wall).toBeNull()
|
||||
expect(turn.prompt).toBeNull()
|
||||
expect(turn.promptImages).toBe(0)
|
||||
})
|
||||
|
||||
it('sums measured spans and reports partial coverage as a lower bound', () => {
|
||||
const measured = metricsOf({ startTime: 1_000, ttft: 100, model: 400, decode: 300, output: 40, tool: 200 })
|
||||
const unknown = metricsOf({ startTime: 2_000, toolPending: 2 })
|
||||
const turn = aggregateTurn(3, [measured, unknown])
|
||||
expect(turn.ttft).toBe(100)
|
||||
expect(turn.ttftPending).toBe(1)
|
||||
expect(turn.model).toBe(400)
|
||||
expect(turn.modelPending).toBe(1)
|
||||
expect(turn.decode).toBe(300)
|
||||
expect(turn.output).toBe(40)
|
||||
expect(turn.tool).toBe(200)
|
||||
expect(turn.toolPending).toBe(2)
|
||||
expect(turn.startTime).toBe(1_000)
|
||||
expect(turn.running).toBe(false)
|
||||
})
|
||||
|
||||
it('reduces an entirely unmeasured turn to blanks', () => {
|
||||
const turn = aggregateTurn(3, [metricsOf(), metricsOf({ step: 2 })])
|
||||
expect(turn.ttft).toBeNull()
|
||||
expect(turn.ttftPending).toBe(0)
|
||||
expect(turn.model).toBeNull()
|
||||
expect(turn.modelPending).toBe(2)
|
||||
expect(turn.decode).toBeNull()
|
||||
expect(turn.output).toBeNull()
|
||||
expect(turn.tool).toBeNull()
|
||||
expect(turn.toolPending).toBe(0)
|
||||
expect(turn.tps).toBeNull()
|
||||
expect(turn.wall).toBeNull()
|
||||
})
|
||||
|
||||
it('derives turn speed only when decode and output are both positive', () => {
|
||||
const fast = aggregateTurn(1, [
|
||||
metricsOf({ step: 1, decode: 2_000, output: 50 }),
|
||||
metricsOf({ step: 2, decode: 2_000, output: 50 }),
|
||||
])
|
||||
expect(fast.tps).toBeCloseTo(100 / 4)
|
||||
const noDecode = aggregateTurn(1, [metricsOf({ decode: 0, output: 50 })])
|
||||
expect(noDecode.decode).toBe(0)
|
||||
expect(noDecode.tps).toBeNull()
|
||||
const noOutput = aggregateTurn(1, [metricsOf({ decode: 2_000, output: 0 })])
|
||||
expect(noOutput.tps).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the last measured context and cache level of the turn', () => {
|
||||
const turn = aggregateTurn(4, [
|
||||
metricsOf({ step: 1, context: 100, cacheHit: 0.2, cacheRead: 10, cacheWrite: 5, wall: 10 }),
|
||||
metricsOf({ step: 2, context: 200, cacheHit: 0.5, cacheRead: 20, cacheWrite: 6, wall: 20 }),
|
||||
metricsOf({ step: 3, context: 300, wall: 30 }),
|
||||
])
|
||||
expect(turn.context).toBe(300)
|
||||
expect(turn.cacheHit).toBe(0.5)
|
||||
expect(turn.cacheRead).toBe(20)
|
||||
expect(turn.cacheWrite).toBe(6)
|
||||
expect(turn.lastStepWall).toBe(30)
|
||||
const heldBack = aggregateTurn(4, [
|
||||
metricsOf({ step: 1, context: 100, cacheHit: 0.2, cacheRead: 10, cacheWrite: 5, wall: 10 }),
|
||||
metricsOf({ step: 2, cacheHit: null, cacheRead: 99, cacheWrite: 99, wall: null }),
|
||||
])
|
||||
expect(heldBack.context).toBe(100)
|
||||
expect(heldBack.cacheHit).toBe(0.2)
|
||||
expect(heldBack.cacheRead).toBe(10)
|
||||
expect(heldBack.cacheWrite).toBe(5)
|
||||
expect(heldBack.lastStepWall).toBe(10)
|
||||
const bare = aggregateTurn(4, [metricsOf({ cacheHit: 0.4 })])
|
||||
expect(bare.cacheHit).toBe(0.4)
|
||||
expect(bare.cacheRead).toBeNull()
|
||||
expect(bare.cacheWrite).toBeNull()
|
||||
})
|
||||
|
||||
it('sums the sequential step tool spans', () => {
|
||||
const turn = aggregateTurn(5, [
|
||||
metricsOf({ step: 1, toolWall: 300, tool: 400 }),
|
||||
metricsOf({ step: 2, toolWall: 200, tool: 250 }),
|
||||
metricsOf({ step: 3 }),
|
||||
])
|
||||
expect(turn.toolWall).toBe(500)
|
||||
expect(turn.tool).toBe(650)
|
||||
expect(aggregateTurn(5, [metricsOf(), metricsOf({ step: 2 })]).toolWall).toBeNull()
|
||||
})
|
||||
|
||||
it('never confuses the summed tool time with the wall union', () => {
|
||||
const turn = aggregateTurn(6, [metricsOf({ step: 1, tool: 200, toolWall: 100 })])
|
||||
expect(turn.tool).toBe(200)
|
||||
expect(turn.toolWall).toBe(100)
|
||||
})
|
||||
|
||||
it('sums retries, tool errors, and tool output', () => {
|
||||
const turn = aggregateTurn(5, [
|
||||
metricsOf({ step: 1, retries: 1, toolErrors: 2, toolOutput: 10 }),
|
||||
metricsOf({ step: 2, retries: 2, toolErrors: 3, toolOutput: 5 }),
|
||||
])
|
||||
expect(turn.retries).toBe(3)
|
||||
expect(turn.toolErrors).toBe(5)
|
||||
expect(turn.toolOutput).toBe(15)
|
||||
const unrecorded = aggregateTurn(5, [
|
||||
metricsOf({ step: 1, toolOutput: null }),
|
||||
metricsOf({ step: 2, toolOutput: 4 }),
|
||||
])
|
||||
expect(unrecorded.toolOutput).toBe(4)
|
||||
})
|
||||
|
||||
it('merges tool tallies across steps', () => {
|
||||
const turn = aggregateTurn(6, [
|
||||
metricsOf({ step: 1, tools: [{ name: 'read', count: 1 }] }),
|
||||
metricsOf({ step: 2, tools: [{ name: 'read', count: 2 }, { name: 'grep', count: 1 }] }),
|
||||
])
|
||||
expect(turn.tools).toEqual([{ name: 'read', count: 3 }, { name: 'grep', count: 1 }])
|
||||
const tied = aggregateTurn(6, [
|
||||
metricsOf({ step: 1, tools: [{ name: 'read', count: 1 }, { name: 'grep', count: 1 }] }),
|
||||
])
|
||||
expect(tied.tools).toEqual([{ name: 'read', count: 1 }, { name: 'grep', count: 1 }])
|
||||
})
|
||||
|
||||
it('measures turn wall time only from a settled last step with a start', () => {
|
||||
const first = metricsOf({ step: 1, startTime: 1_000 })
|
||||
const last = metricsOf({ step: 2, startTime: 2_000, endTime: 3_000 })
|
||||
expect(aggregateTurn(1, [first, last]).wall).toBe(2_000)
|
||||
expect(aggregateTurn(1, [first, { ...last, running: true }]).wall).toBeNull()
|
||||
expect(aggregateTurn(1, [{ ...first, startTime: null }, last]).wall).toBeNull()
|
||||
expect(aggregateTurn(1, [first, { ...last, endTime: null }]).wall).toBeNull()
|
||||
expect(aggregateTurn(1, [first, { ...last, endTime: 500 }]).wall).toBe(0)
|
||||
})
|
||||
|
||||
it('reports last-step liveness and any interruption in the turn', () => {
|
||||
const running = aggregateTurn(1, [
|
||||
metricsOf({ step: 1, running: false }),
|
||||
metricsOf({ step: 2, running: true }),
|
||||
])
|
||||
expect(running.running).toBe(true)
|
||||
const interrupted = aggregateTurn(2, [
|
||||
metricsOf({ step: 1, interrupted: true }),
|
||||
metricsOf({ step: 2, aborted: true }),
|
||||
])
|
||||
expect(interrupted.interrupted).toBe(true)
|
||||
expect(interrupted.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('describes the turn prompt as absent until the table attributes it', () => {
|
||||
const turn = aggregateTurn(2, [metricsOf()])
|
||||
expect(turn.prompt).toBeNull()
|
||||
expect(turn.promptImages).toBe(0)
|
||||
expect(turn.steps).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('turn grouping', () => {
|
||||
/** Wrap one step metrics value in the node envelope the builder receives. */
|
||||
function stepNode(metrics: PerfStepMetrics): PerfViewNode {
|
||||
return {
|
||||
key: `${metrics.turn}:${metrics.step}`,
|
||||
kind: 'perf-step',
|
||||
id: `${metrics.turn}:${metrics.step}`,
|
||||
target: 'perf',
|
||||
anchorSeq: metrics.startTime ?? 0,
|
||||
location: { kind: 'unresolved' },
|
||||
data: { kind: 'step', metrics },
|
||||
}
|
||||
}
|
||||
|
||||
it('groups steps into ordered turns with the question and markers', () => {
|
||||
const one = completedStep(8, 1, 20_000)
|
||||
const two = completedStep(8, 2, 21_000)
|
||||
const open: PerfStepMetrics = {
|
||||
...completedStep(9, 1, 30_000),
|
||||
endTime: null,
|
||||
wall: null,
|
||||
running: true,
|
||||
}
|
||||
const nodes: PerfViewNode[] = [
|
||||
stepNode(two),
|
||||
it('groups steps into turns ordered oldest first with their markers', () => {
|
||||
const one = metricsOf({ turn: 8, step: 2, startTime: 20_000 })
|
||||
const two = metricsOf({ turn: 8, step: 1, startTime: 19_000 })
|
||||
const later = metricsOf({ turn: 9, step: 1, startTime: 30_000 })
|
||||
const turns = buildTurns([
|
||||
stepNode(later),
|
||||
stepNode(one),
|
||||
stepNode(open),
|
||||
{
|
||||
key: '8:0',
|
||||
kind: 'perf-prompt',
|
||||
id: '8:0',
|
||||
target: 'perf',
|
||||
anchorSeq: 1,
|
||||
location: { kind: 'unresolved' },
|
||||
data: { kind: 'prompt', turn: 8, prompt: '问题', images: 2 },
|
||||
},
|
||||
{
|
||||
key: '9:100000',
|
||||
kind: 'perf-turn-end',
|
||||
id: '9:100000',
|
||||
target: 'perf',
|
||||
anchorSeq: 2,
|
||||
location: { kind: 'unresolved' },
|
||||
data: { kind: 'end', turn: 9, closeKind: 'interrupted', endTime: 31_000 },
|
||||
},
|
||||
]
|
||||
const turns = buildTurns(nodes)
|
||||
stepNode(two),
|
||||
promptNode(8, '问题', 2),
|
||||
endNode(9, 31_000, 'interrupted'),
|
||||
])
|
||||
expect(turns.map(turn => turn.turn)).toEqual([8, 9])
|
||||
expect(turns[0]?.steps.map(step => step.step)).toEqual([1, 2])
|
||||
expect(turns[0]?.prompt).toBe('问题')
|
||||
expect(turns[0]?.promptImages).toBe(2)
|
||||
// The interrupted turn closes its last step at the turn-end time.
|
||||
expect(turns[1]?.interrupted).toBe(true)
|
||||
expect(turns[1]?.steps[0]?.endTime).toBe(31_000)
|
||||
expect(turns[1]?.steps[0]?.wall).toBe(1_000)
|
||||
expect(turns[1]?.steps[0]?.running).toBe(false)
|
||||
expect(turns[1]?.steps[0]?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('never leaves a step running when a later step exists', () => {
|
||||
const running = { ...completedStep(10, 1, 40_000), running: true }
|
||||
const next = completedStep(10, 2, 41_000)
|
||||
const turns = buildTurns([stepNode(running), stepNode(next)])
|
||||
expect(turns[0]?.steps[0]?.running).toBe(false)
|
||||
expect(turns[0]?.steps[1]?.running).toBe(false)
|
||||
it('returns no turn for markers without a step', () => {
|
||||
expect(buildTurns([])).toEqual([])
|
||||
expect(buildTurns([promptNode(5, 'orphan'), endNode(5, 1_000, 'interrupted')])).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores blank prompt markers and end markers without detail', () => {
|
||||
const step = metricsOf({ turn: 1, step: 1, startTime: 1_000, running: true })
|
||||
const turns = buildTurns([
|
||||
stepNode(step),
|
||||
promptNode(1, null),
|
||||
promptNode(1, ''),
|
||||
endNode(1, Number.NaN, null),
|
||||
])
|
||||
expect(turns).toHaveLength(1)
|
||||
expect(turns[0]?.prompt).toBeNull()
|
||||
expect(turns[0]?.promptImages).toBe(0)
|
||||
expect(turns[0]?.running).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the first non-blank prompt of a turn', () => {
|
||||
const step = metricsOf({ turn: 1, step: 1 })
|
||||
const turns = buildTurns([
|
||||
stepNode(step),
|
||||
promptNode(1, null),
|
||||
promptNode(1, 'first', 3),
|
||||
promptNode(1, 'second', 4),
|
||||
])
|
||||
expect(turns[0]?.prompt).toBe('first')
|
||||
expect(turns[0]?.promptImages).toBe(3)
|
||||
})
|
||||
|
||||
it('skips a step whose turn number is not a number', () => {
|
||||
const turns = buildTurns([
|
||||
stepNode(metricsOf({ turn: Number.NaN, step: 1 })),
|
||||
stepNode(metricsOf({ turn: 2, step: 1 })),
|
||||
])
|
||||
expect(turns.map(turn => turn.turn)).toEqual([2])
|
||||
})
|
||||
|
||||
it('clears running before a later step without mutating the input', () => {
|
||||
const first = metricsOf({ turn: 1, step: 1, running: true })
|
||||
const second = metricsOf({ turn: 1, step: 2, running: true })
|
||||
const third = metricsOf({ turn: 1, step: 3, running: true })
|
||||
const turns = buildTurns([stepNode(first), stepNode(second), stepNode(third)])
|
||||
expect(turns[0]?.steps.map(step => step.running)).toEqual([false, false, true])
|
||||
expect(first.running).toBe(true)
|
||||
expect(turns[0]?.running).toBe(true)
|
||||
})
|
||||
|
||||
it('clears running when a later turn follows', () => {
|
||||
const turns = buildTurns([
|
||||
stepNode(metricsOf({ turn: 1, step: 1, running: true })),
|
||||
stepNode(metricsOf({ turn: 2, step: 1, running: true })),
|
||||
])
|
||||
expect(turns[0]?.running).toBe(false)
|
||||
expect(turns[1]?.running).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the empty step an interruption appends', () => {
|
||||
const one = completedStep(11, 1, 50_000)
|
||||
const empty = {
|
||||
...one,
|
||||
step: 2,
|
||||
context: null,
|
||||
ttft: null,
|
||||
model: null,
|
||||
tool: null,
|
||||
toolPending: 0,
|
||||
tools: [],
|
||||
wall: 0,
|
||||
const one = metricsOf({ turn: 11, step: 1, startTime: 50_000, context: 300 })
|
||||
for (const wall of [0, null]) {
|
||||
const empty = metricsOf({ turn: 11, step: 2, wall })
|
||||
const turns = buildTurns([
|
||||
stepNode(one),
|
||||
stepNode(empty),
|
||||
endNode(11, 51_000, 'interrupted'),
|
||||
])
|
||||
expect(turns[0]?.steps, String(wall)).toHaveLength(1)
|
||||
expect(turns[0]?.steps[0]?.wall).toBe(1_000)
|
||||
expect(turns[0]?.steps[0]?.aborted).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a tail step that still measured something', () => {
|
||||
const variants: readonly PerfStepMetrics[] = [
|
||||
metricsOf({ turn: 12, step: 2, context: 5 }),
|
||||
metricsOf({ turn: 12, step: 2, ttft: 5 }),
|
||||
metricsOf({ turn: 12, step: 2, model: 5 }),
|
||||
metricsOf({ turn: 12, step: 2, tool: 0 }),
|
||||
metricsOf({ turn: 12, step: 2, toolPending: 1 }),
|
||||
metricsOf({ turn: 12, step: 2, tools: [{ name: 'read', count: 1 }] }),
|
||||
metricsOf({ turn: 12, step: 2, wall: 5 }),
|
||||
]
|
||||
for (const tail of variants) {
|
||||
const turns = buildTurns([
|
||||
stepNode(metricsOf({ turn: 12, step: 1, startTime: 1_000, context: 10 })),
|
||||
stepNode(tail),
|
||||
endNode(12, 2_000, 'interrupted'),
|
||||
])
|
||||
expect(turns[0]?.steps, JSON.stringify(tail)).toHaveLength(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('closes an interrupted tail at the turn end time', () => {
|
||||
const open = metricsOf({ turn: 13, step: 1, startTime: 1_000, running: true })
|
||||
const turns = buildTurns([stepNode(open), endNode(13, 4_000, 'interrupted')])
|
||||
const closed = turns[0]?.steps[0]
|
||||
expect(closed?.endTime).toBe(4_000)
|
||||
expect(closed?.wall).toBe(3_000)
|
||||
expect(closed?.running).toBe(false)
|
||||
expect(closed?.aborted).toBe(true)
|
||||
expect(turns[0]?.wall).toBe(3_000)
|
||||
expect(turns[0]?.running).toBe(false)
|
||||
})
|
||||
|
||||
it('closes a tail with no start time without inventing a duration', () => {
|
||||
const open = metricsOf({ turn: 14, step: 1, running: true })
|
||||
const turns = buildTurns([stepNode(open), endNode(14, 4_000, null)])
|
||||
expect(turns[0]?.steps[0]?.endTime).toBe(4_000)
|
||||
expect(turns[0]?.steps[0]?.wall).toBeNull()
|
||||
expect(turns[0]?.interrupted).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a tail that already ended untouched', () => {
|
||||
const settled = metricsOf({ turn: 15, step: 1, startTime: 1_000, endTime: 1_500 })
|
||||
const turns = buildTurns([stepNode(settled), endNode(15, 4_000, 'completed')])
|
||||
expect(turns[0]?.steps[0]?.endTime).toBe(1_500)
|
||||
expect(turns[0]?.steps[0]?.wall).toBeNull()
|
||||
expect(turns[0]?.wall).toBe(500)
|
||||
})
|
||||
|
||||
it('prefers the interruption close kind over the step flags', () => {
|
||||
const done = metricsOf({ turn: 16, step: 1, interrupted: true })
|
||||
const interrupted = buildTurns([stepNode(done), endNode(16, 1_000, 'interrupted')])
|
||||
expect(interrupted[0]?.interrupted).toBe(true)
|
||||
const unrelated = buildTurns([stepNode(done), endNode(16, 1_000, 'completed')])
|
||||
expect(unrelated[0]?.interrupted).toBe(true)
|
||||
const quiet = buildTurns([stepNode(metricsOf({ turn: 17, step: 1 }))])
|
||||
expect(quiet[0]?.interrupted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Code-Mode contribution merging', () => {
|
||||
it('replaces the runner with the real tools and recomputes the totals', () => {
|
||||
const runner = perfCall('root', 1, { name: 'run_code', chars: 500 })
|
||||
const step = metricsOf({
|
||||
turn: 2,
|
||||
step: 1,
|
||||
calls: [runner],
|
||||
tools: [{ name: 'run_code', count: 1 }],
|
||||
toolOutput: 500,
|
||||
toolWall: 400,
|
||||
toolErrors: 1,
|
||||
tool: 200,
|
||||
})
|
||||
const inner = innerNode(2, 1, [
|
||||
perfCall('sub-1', 2, {
|
||||
name: 'grep', chars: 20, inner: true, startedAt: 1_100, endedAt: 1_150,
|
||||
}),
|
||||
perfCall('sub-2', 3, {
|
||||
name: 'read', chars: 5, inner: true, error: true, startedAt: 1_200, endedAt: 1_300,
|
||||
}),
|
||||
perfCall('sub-3', 4, { name: null, inner: true, chars: null }),
|
||||
])
|
||||
const turns = buildTurns([stepNode(step), inner])
|
||||
const merged = turns[0]?.steps[0]
|
||||
expect(merged?.calls.map(call => call.callId)).toEqual(['sub-1', 'sub-2', 'sub-3'])
|
||||
expect(merged?.tools).toEqual([{ name: 'grep', count: 1 }, { name: 'read', count: 1 }])
|
||||
expect(merged?.toolOutput).toBe(25)
|
||||
expect(merged?.toolWall).toBe(150)
|
||||
expect(merged?.toolErrors).toBe(2)
|
||||
expect(merged?.tool).toBe(200)
|
||||
expect(turns[0]?.tools).toEqual([{ name: 'grep', count: 1 }, { name: 'read', count: 1 }])
|
||||
expect(turns[0]?.toolErrors).toBe(2)
|
||||
expect(turns[0]?.toolWall).toBe(150)
|
||||
})
|
||||
|
||||
it('merges every root contribution of one step in sequence order', () => {
|
||||
const step = metricsOf({ turn: 3, step: 1 })
|
||||
const turns = buildTurns([
|
||||
stepNode(one),
|
||||
stepNode(empty),
|
||||
{
|
||||
key: '11:100000',
|
||||
kind: 'perf-turn-end',
|
||||
id: '11:100000',
|
||||
target: 'perf',
|
||||
anchorSeq: 2,
|
||||
location: { kind: 'unresolved' },
|
||||
data: { kind: 'end', turn: 11, closeKind: 'interrupted', endTime: 51_000 },
|
||||
},
|
||||
stepNode(step),
|
||||
innerNode(3, 1, [perfCall('late', 5, { name: 'write', inner: true, chars: 2 })]),
|
||||
innerNode(3, 1, [perfCall('early', 2, { name: 'grep', inner: true, chars: 3 })]),
|
||||
])
|
||||
expect(turns[0]?.steps[0]?.calls.map(call => call.callId)).toEqual(['early', 'late'])
|
||||
expect(turns[0]?.steps[0]?.toolOutput).toBe(5)
|
||||
})
|
||||
|
||||
it('caps the merged call list at the table limit', () => {
|
||||
const step = metricsOf({ turn: 4, step: 1 })
|
||||
const calls = Array.from({ length: CALL_LIMIT + 3 }, (_, index) => perfCall(
|
||||
`inner-${index}`,
|
||||
index + 1,
|
||||
{ inner: true, name: 'read', chars: index + 1 },
|
||||
))
|
||||
const turns = buildTurns([stepNode(step), innerNode(4, 1, calls)])
|
||||
expect(turns[0]?.steps[0]?.calls).toHaveLength(CALL_LIMIT)
|
||||
expect(turns[0]?.steps[0]?.calls[0]?.callId).toBe('inner-0')
|
||||
expect(turns[0]?.steps[0]?.calls.at(-1)?.callId).toBe(`inner-${CALL_LIMIT - 1}`)
|
||||
expect(turns[0]?.steps[0]?.toolOutput).toBe(
|
||||
Array.from({ length: CALL_LIMIT }, (_, index) => index + 1).reduce((sum, value) => sum + value, 0),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the runner when no inner contribution arrived', () => {
|
||||
const runner = perfCall('root', 1, { name: 'run_code', chars: 500 })
|
||||
const turns = buildTurns([stepNode(metricsOf({
|
||||
turn: 5,
|
||||
step: 1,
|
||||
calls: [runner],
|
||||
tools: [{ name: 'run_code', count: 1 }],
|
||||
toolOutput: 500,
|
||||
}))])
|
||||
expect(turns[0]?.steps[0]?.calls.map(call => call.callId)).toEqual(['root'])
|
||||
expect(turns[0]?.tools).toEqual([{ name: 'run_code', count: 1 }])
|
||||
})
|
||||
|
||||
it('ignores inner contributions for steps the window no longer holds', () => {
|
||||
const step = metricsOf({ turn: 6, step: 1, toolOutput: 7 })
|
||||
const turns = buildTurns([stepNode(step), innerNode(6, 2, [perfCall('sub', 2, {
|
||||
name: 'grep', chars: 9, inner: true,
|
||||
})])])
|
||||
expect(turns[0]?.steps).toHaveLength(1)
|
||||
expect(turns[0]?.steps[0]?.wall).not.toBeNull()
|
||||
expect(turns[0]?.steps[0]?.calls).toEqual([])
|
||||
expect(turns[0]?.toolOutput).toBe(7)
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,6 @@ import { en, NS, zh } from '../src/client/locales.ts'
|
||||
import type {
|
||||
PerfCall, PerfContribution, PerfSnapshot, PerfStepMetrics, PerfStepState, PerfViewNode,
|
||||
} from '../src/client/perf-contract.ts'
|
||||
import { resetCurrentTurn, resetPtcSteps } from '../src/client/perf-fold.ts'
|
||||
import { TARGET } from '../src/client/perf-definition.ts'
|
||||
import { PerfView } from '../src/client/PerfView.tsx'
|
||||
import {
|
||||
@@ -115,6 +114,7 @@ interface PluginBench {
|
||||
readonly views: readonly ConversationViewDefinition[]
|
||||
readonly localeRegister: ReturnType<typeof vi.fn>
|
||||
readonly binding: ReturnType<typeof vi.fn>
|
||||
readonly loadOlder: ReturnType<typeof vi.fn>
|
||||
readonly entries: () => readonly StoredEntry[]
|
||||
readonly setSnapshot: (next: PerfSnapshot | undefined) => void
|
||||
readonly notify: () => void
|
||||
@@ -139,6 +139,9 @@ async function mountPlugin(): Promise<PluginBench> {
|
||||
},
|
||||
}),
|
||||
}))
|
||||
const loadOlder = vi.fn(async () => undefined)
|
||||
const sessionBinding = { session: { loadOlder } }
|
||||
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,
|
||||
@@ -169,6 +172,7 @@ async function mountPlugin(): Promise<PluginBench> {
|
||||
views,
|
||||
localeRegister,
|
||||
binding,
|
||||
loadOlder,
|
||||
entries: () => ctx.slots.entries('conversation.view'),
|
||||
setSnapshot: (next) => { snapshot = next },
|
||||
notify: () => { listener?.() },
|
||||
@@ -196,8 +200,6 @@ function assemble(
|
||||
|
||||
/** The Definitions captured by one plugin registration. */
|
||||
async function registeredDefinitions(): Promise<readonly ConversationNodeDefinition[]> {
|
||||
resetCurrentTurn()
|
||||
resetPtcSteps()
|
||||
const bench = await mountPlugin()
|
||||
const definitions = bench.events
|
||||
await bench.fiber.dispose()
|
||||
@@ -223,6 +225,8 @@ function measuredStep(turn: number, step: number, startTime: number, endTime: nu
|
||||
text: 'one\ntwo',
|
||||
truncated: false,
|
||||
ms: 200,
|
||||
startedAt: startTime + 50,
|
||||
endedAt: endTime - 10,
|
||||
}
|
||||
return {
|
||||
turn,
|
||||
@@ -236,6 +240,7 @@ function measuredStep(turn: number, step: number, startTime: number, endTime: nu
|
||||
tps: 40 / 0.3,
|
||||
output: 40,
|
||||
tool: 200,
|
||||
toolWall: 200,
|
||||
tools: [{ name: 'read', count: 1 }],
|
||||
calls: [call],
|
||||
toolOutput: 7,
|
||||
@@ -296,13 +301,47 @@ function contextOf(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 })
|
||||
expect(bench.events.map(definition => definition.kind)).toEqual([
|
||||
'perf-step', 'perf-prompt', 'perf-turn-end',
|
||||
'perf-step', 'perf-prompt', 'perf-turn-end', 'perf-code-mode',
|
||||
])
|
||||
expect(clientInject).toEqual(['slots', 'sessions', 'uiConversation', 'locale'])
|
||||
expect(bench.views).toEqual([perfViewDefinition])
|
||||
expect(bench.entries()).toHaveLength(1)
|
||||
const registered = bench.entries()[0]!
|
||||
@@ -332,6 +371,11 @@ describe('browser plugin registration', () => {
|
||||
bench.notify()
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
unsubscribe()
|
||||
expect(await injected.loadOlder()).toBe(false)
|
||||
expect(bench.loadOlder).toHaveBeenCalledTimes(1)
|
||||
// The source is cached per Session binding; a second injection reuses it.
|
||||
expect(injectEntry!(SID).hooks.perf).toBe(injected.hooks.perf)
|
||||
expect(() => injectEntry!('perf-missing' as SessionId)).toThrow(/unavailable/)
|
||||
await bench.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -347,7 +391,8 @@ describe('locale dictionaries', () => {
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
|
||||
expect(Object.values(en).every(value => value !== '')).toBe(true)
|
||||
expect(en['view.perf']).toBe('Performance')
|
||||
expect(en['page.info']).toContain('{page}')
|
||||
expect(en['summary']).toContain('{turns}')
|
||||
expect(en['loadOlder']).toBe('Load earlier')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -445,17 +490,6 @@ describe('conversation Definitions', () => {
|
||||
expect(turn.steps[0]?.running).toBe(false)
|
||||
})
|
||||
|
||||
it('closes a turn whose reason carries no kind', async () => {
|
||||
const definitions = await registeredDefinitions()
|
||||
const snapshot = assemble(definitions, [
|
||||
entry(event('turn/start', { turn: 3 })),
|
||||
entry(event('step/start', { turn: 3, step: 1 }, 6_000)),
|
||||
entry(event('step/end', { turn: 3, step: 1 }, 6_200)),
|
||||
entry(event('turn/end', { turn: 3, reason: {} }, 6_200)),
|
||||
])
|
||||
expect(snapshot!.turns[0]?.interrupted).toBe(false)
|
||||
})
|
||||
|
||||
it('folds a trimmed window from the update events it still holds', async () => {
|
||||
const definitions = await registeredDefinitions()
|
||||
const snapshot = assemble(definitions, [
|
||||
@@ -529,48 +563,151 @@ describe('conversation Definitions', () => {
|
||||
expect(snapshot!.turns[0]!.steps[0]!.ttft).toBe(300)
|
||||
})
|
||||
|
||||
it('reports a prompt Definition only after a turn start', async () => {
|
||||
it('publishes a prompt node from a human-typed user message location', async () => {
|
||||
const definitions = await registeredDefinitions()
|
||||
const prompt = definitions[1] as ConversationNodeDefinition<PerfStepState>
|
||||
expect(prompt.match(event('turn/start', { turn: 9 }))).toBeNull()
|
||||
expect(prompt.match(event('user/message', {
|
||||
role: 'user', id: 'u4', content: [{ type: 'text', text: '<system-reminder>' }], source: { kind: 'runtime-context' },
|
||||
}))).toBeNull()
|
||||
const user = event('user/message', {
|
||||
role: 'user', id: 'u6', content: [{ type: 'text', text: '继续' }, { type: '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).toBe(1)
|
||||
const node = prompt.buildViewNode!({
|
||||
...contextOf(prompt, state, [match], match),
|
||||
}) as PerfViewNode
|
||||
expect(node.data).toEqual({ kind: 'prompt', turn: 9, prompt: '继续', images: 1 })
|
||||
const context: ConversationNodeContext<PerfStepState> & { readonly state: PerfStepState } = {
|
||||
...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<PerfStepState>
|
||||
const user = event('user/message', {
|
||||
role: 'user', id: 'u3', content: [{ type: 'text', text: '无轮次' }], source: { kind: 'user' },
|
||||
role: 'user', id: 'u7', content: [{ type: 'text', text: '问题' }], source: { kind: 'user' },
|
||||
})
|
||||
expect(prompt.match(user)).toBeNull()
|
||||
resetCurrentTurn()
|
||||
expect(prompt.match(event('turn/start', { turn: 9 }))).toEqual({ id: '9:0', role: 'start' })
|
||||
const state = prompt.start(
|
||||
contextOf(prompt, undefined),
|
||||
startMatch(event('turn/start', { turn: 9 })),
|
||||
reader,
|
||||
)
|
||||
expect(state.turn).toBe(9)
|
||||
expect(state.mark).toBe('prompt')
|
||||
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 PerfViewNode
|
||||
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('ignores user messages that are not typed prompts', async () => {
|
||||
it('folds Code-Mode dispatches into one node per root call', async () => {
|
||||
const definitions = await registeredDefinitions()
|
||||
const prompt = definitions[1] as ConversationNodeDefinition<PerfStepState>
|
||||
const turnStart = event('turn/start', { turn: 10 })
|
||||
const promptState = prompt.start(
|
||||
contextOf(prompt, undefined),
|
||||
startMatch(turnStart),
|
||||
const codeMode = definitions[3] as ConversationNodeDefinition<PerfStepState>
|
||||
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<PerfStepState> & { readonly state: PerfStepState } = {
|
||||
...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 PerfViewNode
|
||||
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<PerfStepState>
|
||||
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 PerfViewNode
|
||||
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 PerfViewNode
|
||||
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<PerfStepState>
|
||||
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,
|
||||
)
|
||||
const runtime = event('user/message', {
|
||||
role: 'user', id: 'u4', content: [{ type: 'text', text: '<system-reminder>' }], source: { kind: 'runtime-context' },
|
||||
})
|
||||
expect(prompt.match(runtime)).toBeNull()
|
||||
expect(prompt.match(event('user/message', { role: 'user', id: 'u5', content: [] }))).toBeNull()
|
||||
expect(prompt.match(event('step/start', { turn: 10, step: 1 }))).toBeNull()
|
||||
const typed = event('user/message', {
|
||||
role: 'user', id: 'u6', content: [{ type: 'text', text: '继续' }], source: { kind: 'user' },
|
||||
})
|
||||
expect(prompt.match(typed)).toEqual({ id: '10:0', role: 'update' })
|
||||
const context: ConversationNodeContext<PerfStepState> & { readonly state: PerfStepState } = {
|
||||
...contextOf(prompt, promptState, [updateMatch(typed)]),
|
||||
state: promptState,
|
||||
}
|
||||
expect(prompt.update(context, updateMatch(typed)).prompt).toBe('继续')
|
||||
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 () => {
|
||||
@@ -600,10 +737,10 @@ describe('conversation Definitions', () => {
|
||||
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<PerfStepState>
|
||||
expect(prompt.publication!(startMatch(event('turn/start', { turn: 1 })))).toBe('none')
|
||||
expect(prompt.publication!(updateMatch(event('user/message', {
|
||||
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 () => {
|
||||
@@ -639,33 +776,15 @@ describe('conversation Definitions', () => {
|
||||
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 stepLocation: ConversationLocation = {
|
||||
kind: 'step',
|
||||
turn: {
|
||||
turn: 11,
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
status: 'open',
|
||||
steps: [],
|
||||
data: { get: () => undefined, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) },
|
||||
},
|
||||
step: {
|
||||
turn: 11,
|
||||
step: 2,
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
status: 'unknown',
|
||||
data: { get: () => undefined, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) },
|
||||
},
|
||||
}
|
||||
const trimmed = step.buildViewNode!({
|
||||
...contextOf(step,
|
||||
{ ...folded, startSeq: null }, [{ ...update, location: stepLocation }]),
|
||||
{ ...folded, startSeq: null }, [{ ...update, location: stepAt(11, 2) }]),
|
||||
}) as PerfViewNode
|
||||
expect(trimmed.anchorSeq).toBe(0)
|
||||
expect(trimmed.location).toEqual(stepLocation)
|
||||
expect(trimmed.location).toEqual(stepAt(11, 2))
|
||||
const unresolved = step.buildViewNode!({
|
||||
...contextOf(step, { ...folded, startSeq: null }, []),
|
||||
}) as PerfViewNode
|
||||
@@ -682,33 +801,20 @@ describe('conversation Definitions', () => {
|
||||
[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: [] } }), '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()
|
||||
// A dispatch with no recorded root call owns no step.
|
||||
expect(step.match(event('tool/ptc-dispatch', {
|
||||
rootCallId: 'missing', parentCallId: 'missing', subCallId: 's',
|
||||
name: 'grep', arguments: '{}', isError: false, content: [],
|
||||
}))).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 prompt = definitions[1] as ConversationNodeDefinition<PerfStepState>
|
||||
const promptStart = startMatch(event('turn/start', { turn: 13 }))
|
||||
const promptState = prompt.start(
|
||||
contextOf(prompt, undefined),
|
||||
promptStart,
|
||||
reader,
|
||||
)
|
||||
const promptNode = prompt.buildViewNode!({
|
||||
...contextOf(prompt, promptState, [], promptStart),
|
||||
}) as PerfViewNode
|
||||
expect(promptNode.data).toMatchObject({ kind: 'prompt', turn: 13 })
|
||||
const end = definitions[2] as ConversationNodeDefinition<PerfStepState>
|
||||
const endStart = startMatch(event('turn/end', { turn: 13, reason: { kind: 'interrupted' } }, 13_500))
|
||||
const ended = end.start(
|
||||
@@ -725,18 +831,8 @@ describe('conversation Definitions', () => {
|
||||
expect(end.match(event('step/start', { turn: 13, step: 1 }))).toBeNull()
|
||||
})
|
||||
|
||||
it('anchors marker nodes at zero without a recorded start', async () => {
|
||||
it('keeps the end state and anchors it at zero without a recorded start', async () => {
|
||||
const definitions = await registeredDefinitions()
|
||||
const prompt = definitions[1] as ConversationNodeDefinition<PerfStepState>
|
||||
const promptState = prompt.start(
|
||||
contextOf(prompt, undefined),
|
||||
startMatch(event('turn/start', { turn: 14 })),
|
||||
reader,
|
||||
)
|
||||
const promptNode = prompt.buildViewNode!({
|
||||
...contextOf(prompt, promptState),
|
||||
}) as PerfViewNode
|
||||
expect(promptNode.anchorSeq).toBe(0)
|
||||
const end = definitions[2] as ConversationNodeDefinition<PerfStepState>
|
||||
const endState = end.start(
|
||||
contextOf(end, undefined),
|
||||
|
||||
Generated
+9
@@ -8974,6 +8974,9 @@ importers:
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/session-controller
|
||||
'@deepseek-ai/dsh-attachment':
|
||||
specifier: workspace:*
|
||||
version: link:../../attachment/attachment
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:*
|
||||
version: link:../../client/locale
|
||||
@@ -8998,9 +9001,15 @@ importers:
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:*
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-llm-retry':
|
||||
specifier: workspace:*
|
||||
version: link:../../llm/llm-retry
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-tool-executor-ptc':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/tool-executor-ptc
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:*
|
||||
version: link:../../core/tools
|
||||
|
||||
Reference in new issue
Block a user