diff --git a/packages/experimental/client-ui-perf/README.i18n.yaml b/packages/experimental/client-ui-perf/README.i18n.yaml index 9678ffd4f8..a5c9a7569c 100644 --- a/packages/experimental/client-ui-perf/README.i18n.yaml +++ b/packages/experimental/client-ui-perf/README.i18n.yaml @@ -9,7 +9,7 @@ en: 092989f386525568 zh: 4a6276cfc268c851 /deepseek-ai-dsh-experimental-client-ui-perf/summary: - en: dca2348ccb597cec + en: 9c468986b85c1563 zh: 7eda50b8dd62ecde /deepseek-ai-dsh-experimental-client-ui-perf/table-of-contents: en: d152484eb41ac6b4 @@ -36,8 +36,8 @@ en: 7605bb868f93b4a8 zh: 184ca10ce6680499 /deepseek-ai-dsh-experimental-client-ui-perf/known-limitations-and-deferred-work: - en: 50b229ff5d154c7c - zh: 7ed7e9b2dd82fc48 + en: 9f52f1b4e4e75cf5 + zh: b6557b7415ce29af /deepseek-ai-dsh-experimental-client-ui-perf/known-limitations-and-deferred-work/dev-note: en: 3518dbaf30fb19a6 zh: c3c929308e955ebb diff --git a/packages/experimental/client-ui-perf/README.md b/packages/experimental/client-ui-perf/README.md index 19bac60b5b..bc0036d218 100644 --- a/packages/experimental/client-ui-perf/README.md +++ b/packages/experimental/client-ui-perf/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Adds a Performance tab beside Chat and Trajectory. Each turn is one row; expanding it lists its steps with context tokens, output tokens, cache hit rate, first-token latency, model time, output speed, tool time, and a total-time bar split into first token, generation, tools, and the rest. A running step shows elapsed time instead of a blank. Scrolling to the top pulls earlier turns through the same Session paging the other views use; there are no page controls. The panel reads Session events only; it registers no tool, prompt section, or service. +Adds a Performance tab beside Chat and Trajectory. Each turn is one row; expanding it lists its steps with context tokens, output tokens, cache hit rate, first-token latency, model time, output speed, tool time, and a total-time bar split into first token, generation, tools, and the rest. A running step shows elapsed time instead of a blank. Scrolling to the top pulls earlier turns through the same Session paging the other views use; there are no page controls. When a step has no tool calls, its answer label is followed by the first line of the model reply, with the full text on hover. The Question column only carries human-typed prompts: turns woken by a team message, a subagent settlement, a goal round, or runtime context leave it blank. The panel reads Session events only; it registers no tool, prompt section, or service. ## Table of Contents @@ -89,6 +89,7 @@ No effect. The package never constructs a model request and never mutates a prom - **Trimmed windows lose start times.** When the loaded window begins mid-step, first-token, model time, and total time cannot be derived; the row says `start unknown` instead of estimating. - **Code-Mode dispatches merge by root-call context.** Inner calls carry no turn or step, so each root call opens its own context keyed by the root call id, and the builder merges its dispatches into the step that owns the runner. A window that lost the dispatch start still merges the calls but cannot show their measured duration. - **Browser evidence is a test-only overlay.** Package tests cover folding, aggregation, formatting, and the jsdom table; `apps/web/tests/client-ui-perf-panel.e2e.ts` mounts the plugin through a test-only overlay over the shipped Web surface and compares a committed ARIA golden. No recorded-session scenario exists because no shipped profile mounts the package yet. +- **Late re-deliveries are not turns.** Compaction can restate a pruned tool result under its original turn and step after that turn left the window; a turn with no loaded start, prompt, or end marker is dropped instead of appearing as an empty ghost row. - **Timing is derived, not sampled.** Total time is the last step's end minus the first step's start; queueing and scheduler waits land in the "rest" segment. The total-time bar draws tool calls as the union of their paired intervals, so parallel calls count once; the tool column keeps the summed per-call time. diff --git a/packages/experimental/client-ui-perf/README.zh.md b/packages/experimental/client-ui-perf/README.zh.md index 97391948b8..cb8a943c35 100644 --- a/packages/experimental/client-ui-perf/README.zh.md +++ b/packages/experimental/client-ui-perf/README.zh.md @@ -89,6 +89,7 @@ kind: "package-reference" - **窗口被截断时没有起始时刻。** 已加载窗口从半步中间开始时,首 token、模型用时和总用时都推导不出来;行上标「起止未知」,不估算。 - **Code-Mode 内层调用按根调用上下文并回步骤。** 内层调用不带 turn/step,因此每个根调用各自开一个以 root call id 为键的上下文,由 builder 把它的派发并回持有 runner 的那一步。窗口里丢了派发起点时仍会合并调用,但无法给出它们各自的实测耗时。 - **浏览器证据是仅测试用的 overlay。** 包级测试覆盖折叠、汇总、格式化与 jsdom 表格;`apps/web/tests/client-ui-perf-panel.e2e.ts` 用仅测试用的 overlay 把插件挂到真实 Web 面上,并比对提交的 ARIA golden。由于还没有任何 shipped profile 挂载本包,暂无录制会话场景。 +- **迟到重投不是轮次。** 压缩可能把被裁剪的工具结果按原来的轮次/步骤在之后重新投递;窗口里既没有加载到的起点、也没有问题或结束标记的轮次会被丢弃,不再显示成空的幽灵行。 - **计时是推导值。** 总用时取最后一步结束减第一步开始;排队和调度等待落在「其余」段里。总用时条把工具调用画成配对区间之并,并行调用只算一次;工具列仍显示逐次求和。 diff --git a/packages/experimental/client-ui-perf/src/client/PerfView.tsx b/packages/experimental/client-ui-perf/src/client/PerfView.tsx index 4b10c076e5..32b12a921d 100644 --- a/packages/experimental/client-ui-perf/src/client/PerfView.tsx +++ b/packages/experimental/client-ui-perf/src/client/PerfView.tsx @@ -921,6 +921,14 @@ function PromptCell({ turn, t, className }: PromptCellProps): ReactElement { } /** Step did column: tool calls grouped by name and target, with output sizes. */ +/** One-line answer preview appended beside the answer label. */ +function answerPreview(answer: string | null): ReactNode { + if (answer === null) return null + const line = answer.split('\n').find(part => part.trim() !== '') ?? '' + if (line.trim() === '') return null + return {` · ${line.trim()}`} +} + function stepDidCell(step: PerfStepMetrics, t: PerfTranslate): ReactElement { const nodes: ReactNode[] = [] if (step.toolErrors > 0) { @@ -933,6 +941,7 @@ function stepDidCell(step: PerfStepMetrics, t: PerfTranslate): ReactElement { if (step.tools.length === 0) { const label = step.running ? t('did.working') : step.aborted ? t('did.aborted') : t('did.answer') nodes.push({label}) + nodes.push({answerPreview(step.answer)}) return ( {nodes} @@ -980,8 +989,11 @@ function turnDidCell(turn: PerfTurnMetrics, t: PerfTranslate): ReactElement { if (turn.tools.length === 0) { const label = turn.running ? t('did.working') : turn.aborted ? t('did.aborted') : t('did.answer') return ( - - {label} + + + {label} + {answerPreview(turn.answer)} + ) } diff --git a/packages/experimental/client-ui-perf/src/client/perf-aggregate.ts b/packages/experimental/client-ui-perf/src/client/perf-aggregate.ts index 163154447c..5a5267eff8 100644 --- a/packages/experimental/client-ui-perf/src/client/perf-aggregate.ts +++ b/packages/experimental/client-ui-perf/src/client/perf-aggregate.ts @@ -119,12 +119,14 @@ export function aggregateTurn(turn: number, steps: readonly PerfStepMetrics[]): // Last cache hit measured in the turn: the prompt grows, so the tail value is // the turn's actual level rather than the first step's cold start. let cacheHit: number | null = null + let answer: string | null = null let retries = 0 let toolErrors = 0 let toolOutput = 0 let lastStepWall: number | null = null for (const step of steps) { if (isNumber(step.cacheHit)) cacheHit = step.cacheHit + if (step.answer !== null) answer = step.answer retries += step.retries toolErrors += step.toolErrors if (isNumber(step.toolOutput)) toolOutput += step.toolOutput @@ -165,6 +167,7 @@ export function aggregateTurn(turn: number, steps: readonly PerfStepMetrics[]): wall, prompt: null, promptImages: 0, + answer, } } @@ -225,6 +228,11 @@ export function buildTurns(nodes: Iterable): PerfTurnMetrics[] { /* 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 late re-delivery of a pruned tool result names an old turn and has no + // start, prompt, or end in this window: it is context, not a turn row. + const boundary = prompts.has(turn) || closedAt.has(turn) + || steps.some(step => step.startTime !== null || step.mark === 'end') + if (!boundary) return // A step followed by another step cannot still be running: a trimmed window // can lose its step/end, and the row would otherwise stay "running" forever. for (let at = 0; at < steps.length - 1; at += 1) { diff --git a/packages/experimental/client-ui-perf/src/client/perf-contract.ts b/packages/experimental/client-ui-perf/src/client/perf-contract.ts index 5ff035060c..49dae7e74e 100644 --- a/packages/experimental/client-ui-perf/src/client/perf-contract.ts +++ b/packages/experimental/client-ui-perf/src/client/perf-contract.ts @@ -73,6 +73,8 @@ export interface PerfStepState { readonly images: number readonly closed: boolean readonly closeKind: string | null + /** Trimmed one-message answer text, for the Did column preview. */ + readonly answer: string | null } /** Measured metrics of one step. */ @@ -95,6 +97,8 @@ export interface PerfStepMetrics { readonly toolOutput: number | null readonly retries: number readonly toolErrors: number + /** Trimmed answer text of the settled assistant message, or null. */ + readonly answer: string | null readonly cacheRead: number | null readonly cacheWrite: number | null readonly cacheHit: number | null @@ -143,6 +147,8 @@ export interface PerfTurnMetrics { readonly wall: number | null readonly prompt: string | null readonly promptImages: number + /** Last answer preview measured in the turn. */ + readonly answer: string | null } /** One published node: a step's metrics or a turn-level marker. */ diff --git a/packages/experimental/client-ui-perf/src/client/perf-fold.ts b/packages/experimental/client-ui-perf/src/client/perf-fold.ts index e8b7f7d451..238296dc02 100644 --- a/packages/experimental/client-ui-perf/src/client/perf-fold.ts +++ b/packages/experimental/client-ui-perf/src/client/perf-fold.ts @@ -1,7 +1,7 @@ /** Event folding and per-step metric derivation for the performance table. */ import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client' -import type { ContentBlock, StreamChunk, UserMessage } from '@deepseek-ai/dsh-llm' +import type { AssistantMessage, 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-tool-executor-ptc/types' @@ -253,6 +253,20 @@ function isTokenDelta(chunk: StreamChunk): boolean { } } +/** Answer characters kept for the one-line Did preview. */ +const ANSWER_LIMIT = 400 + +/** First text of one assistant message, trimmed and capped for the preview. */ +function answerOf(message: AssistantMessage): string | null { + const parts: string[] = [] + for (const block of message.content) { + if (block.type === 'text') parts.push(block.text) + } + const text = parts.join('\n').trim() + if (text === '') return null + return text.length > ANSWER_LIMIT ? text.slice(0, ANSWER_LIMIT) : text +} + /** * Initial state of one step or one turn-level marker. * @param turn - turn number. @@ -291,6 +305,7 @@ export function newState( images: 0, closed: false, closeKind: null, + answer: null, } } @@ -384,6 +399,7 @@ export function foldEvent( messageTime: next.messageTime ?? event.time, usage: event.data.usage ?? next.usage, interrupted: event.data.interrupted === true, + answer: answerOf(event.data.message) ?? next.answer, } } @@ -611,6 +627,7 @@ export function stepMetrics(state: PerfStepState): PerfStepMetrics { toolOutput: outputChars(calls), retries: state.retries, toolErrors: state.toolErrors, + answer: state.answer, cacheRead: cacheReadTokens(state.usage), cacheWrite: cacheWriteTokens(state.usage), cacheHit: cacheHitRate(state.usage), diff --git a/packages/experimental/client-ui-perf/src/client/perf.module.css b/packages/experimental/client-ui-perf/src/client/perf.module.css index e43a12deb4..ed0466653a 100644 --- a/packages/experimental/client-ui-perf/src/client/perf.module.css +++ b/packages/experimental/client-ui-perf/src/client/perf.module.css @@ -209,6 +209,11 @@ } /* Did column: tool names, hints, and output sizes. */ +.answerPreview { + color: var(--dsw-alias-label-tertiary); + font-weight: 400; +} + .didClip { display: inline-block; max-width: 380px; diff --git a/packages/experimental/client-ui-perf/tests/perf-fold.client.spec.ts b/packages/experimental/client-ui-perf/tests/perf-fold.client.spec.ts index b60eb46e97..916a5f6fa6 100644 --- a/packages/experimental/client-ui-perf/tests/perf-fold.client.spec.ts +++ b/packages/experimental/client-ui-perf/tests/perf-fold.client.spec.ts @@ -663,6 +663,23 @@ describe('assistant timeline and usage', () => { expect(refreshed.usage).toEqual({ inputTokens: 5, outputTokens: 6 }) }) + it('keeps a trimmed answer preview and caps long text', () => { + const first = defined(foldEvent(undefined, event('assistant/message', { + turn: 2, step: 1, message: assistantMessage([textBlock('first line\nsecond line')]), stream: [], + }, 1_500))) + expect(first.answer).toBe('first line\nsecond line') + // Non-text blocks do not contribute, and a text-only whitespace message contributes nothing. + const mixed = defined(foldEvent(first, event('assistant/message', { + turn: 2, step: 1, message: assistantMessage([imageBlock(), textBlock(' ')]), stream: [], + }, 1_600))) + expect(mixed.answer).toBe('first line\nsecond line') + const long = 'x'.repeat(450) + const capped = defined(foldEvent(undefined, event('assistant/message', { + turn: 3, step: 1, message: assistantMessage([textBlock(long)]), stream: [], + }, 1_700))) + expect(capped.answer).toHaveLength(400) + }) + it('leaves usage unset when the first message reports none', () => { const state = defined(foldEvent(undefined, event('assistant/message', { turn: 2, step: 1, message: assistantMessage(), stream: [], diff --git a/packages/experimental/client-ui-perf/tests/perf-metrics.client.spec.ts b/packages/experimental/client-ui-perf/tests/perf-metrics.client.spec.ts index ffc6ab9092..e1e7b7543a 100644 --- a/packages/experimental/client-ui-perf/tests/perf-metrics.client.spec.ts +++ b/packages/experimental/client-ui-perf/tests/perf-metrics.client.spec.ts @@ -23,6 +23,7 @@ function metricsOf(overrides: Partial = {}): PerfStepMetrics { toolOutput: 0, retries: 0, toolErrors: 0, + answer: null, cacheRead: null, cacheWrite: null, cacheHit: null, @@ -339,10 +340,21 @@ describe('turn grouping', () => { expect(turns[0]?.promptImages).toBe(3) }) + it('carries the last measured answer preview into the turn', () => { + expect(aggregateTurn(1, []).answer).toBeNull() + expect(aggregateTurn(1, [metricsOf({ answer: 'first' })]).answer).toBe('first') + expect(aggregateTurn(1, [ + metricsOf({ step: 1, answer: 'first' }), + metricsOf({ step: 2 }), + metricsOf({ step: 3, answer: 'third' }), + ]).answer).toBe('third') + }) + 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 })), + promptNode(2, 'q'), ]) expect(turns.map(turn => turn.turn)).toEqual([2]) }) @@ -351,7 +363,7 @@ describe('turn grouping', () => { 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)]) + const turns = buildTurns([stepNode(first), stepNode(second), stepNode(third), promptNode(1, 'q')]) expect(turns[0]?.steps.map(step => step.running)).toEqual([false, false, true]) expect(first.running).toBe(true) expect(turns[0]?.running).toBe(true) @@ -360,7 +372,9 @@ describe('turn grouping', () => { it('clears running when a later turn follows', () => { const turns = buildTurns([ stepNode(metricsOf({ turn: 1, step: 1, running: true })), + promptNode(1, 'q'), stepNode(metricsOf({ turn: 2, step: 1, running: true })), + promptNode(2, 'q'), ]) expect(turns[0]?.running).toBe(false) expect(turns[1]?.running).toBe(true) @@ -435,7 +449,7 @@ describe('turn grouping', () => { 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 }))]) + const quiet = buildTurns([stepNode(metricsOf({ turn: 17, step: 1 })), promptNode(17, 'q')]) expect(quiet[0]?.interrupted).toBe(false) }) }) @@ -462,7 +476,7 @@ describe('Code-Mode contribution merging', () => { }), perfCall('sub-3', 4, { name: null, inner: true, chars: null }), ]) - const turns = buildTurns([stepNode(step), inner]) + const turns = buildTurns([stepNode(step), promptNode(2, 'q'), 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 }]) @@ -479,6 +493,7 @@ describe('Code-Mode contribution merging', () => { const step = metricsOf({ turn: 3, step: 1 }) const turns = buildTurns([ stepNode(step), + promptNode(3, 'q'), innerNode(3, 1, [perfCall('late', 5, { name: 'write', inner: true, chars: 2 })]), innerNode(3, 1, [perfCall('early', 2, { name: 'grep', inner: true, chars: 3 })]), ]) @@ -493,7 +508,7 @@ describe('Code-Mode contribution merging', () => { index + 1, { inner: true, name: 'read', chars: index + 1 }, )) - const turns = buildTurns([stepNode(step), innerNode(4, 1, calls)]) + const turns = buildTurns([stepNode(step), promptNode(4, 'q'), 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}`) @@ -510,14 +525,14 @@ describe('Code-Mode contribution merging', () => { calls: [runner], tools: [{ name: 'run_code', count: 1 }], toolOutput: 500, - }))]) + })), promptNode(5, 'q')]) 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, { + const turns = buildTurns([stepNode(step), promptNode(6, 'q'), innerNode(6, 2, [perfCall('sub', 2, { name: 'grep', chars: 9, inner: true, })])]) expect(turns[0]?.steps).toHaveLength(1) diff --git a/packages/experimental/client-ui-perf/tests/perf-plugin.client.spec.ts b/packages/experimental/client-ui-perf/tests/perf-plugin.client.spec.ts index 9c107846dd..aa32f096cb 100644 --- a/packages/experimental/client-ui-perf/tests/perf-plugin.client.spec.ts +++ b/packages/experimental/client-ui-perf/tests/perf-plugin.client.spec.ts @@ -246,6 +246,7 @@ function measuredStep(turn: number, step: number, startTime: number, endTime: nu toolOutput: 7, retries: 0, toolErrors: 0, + answer: null, cacheRead: 400, cacheWrite: 100, cacheHit: 400 / 600, @@ -490,6 +491,26 @@ describe('conversation Definitions', () => { expect(turn.steps[0]?.running).toBe(false) }) + it('ignores a compaction re-delivery for a turn the window does not hold', async () => { + const definitions = await registeredDefinitions() + const snapshot = assemble(definitions, [ + entry(event('turn/start', { turn: 575 }, 8_000)), + entry(event('step/start', { turn: 575, step: 1 }, 8_100)), + entry(event('assistant/message', { + turn: 575, + step: 1, + message: { id: 'm', role: 'assistant', content: [], source: { provider: 'p', model: 'm' } }, + stream: stream(8_200), + }, 8_200)), + // Compaction restores a pruned result under its original (unloaded) turn. + entry(event('tool/result', { + turn: 451, step: 1, message: { role: 'tool', content: [], source: { callId: 'call-old' } }, + }, 8_300)), + ]) + expect(snapshot?.turns.map(turn => turn.turn)).toEqual([575]) + expect(snapshot?.stepCount).toBe(1) + }) + it('folds a trimmed window from the update events it still holds', async () => { const definitions = await registeredDefinitions() const snapshot = assemble(definitions, [ @@ -509,6 +530,7 @@ describe('conversation Definitions', () => { entry(event('tool/result', { turn: 4, step: 1, message: { role: 'tool', content: [{ type: 'text', text: 'hit' }], source: { callId: 'call-2' } }, }, 7_600)), + entry(event('turn/end', { turn: 4, reason: { kind: 'completed' } }, 7_700)), ]) expect(snapshot?.turns).toHaveLength(1) const step = snapshot!.turns[0]!.steps[0]! diff --git a/packages/experimental/client-ui-perf/tests/perf-view.client.spec.tsx b/packages/experimental/client-ui-perf/tests/perf-view.client.spec.tsx index 3fe4ca6c3a..b54e39fd68 100644 --- a/packages/experimental/client-ui-perf/tests/perf-view.client.spec.tsx +++ b/packages/experimental/client-ui-perf/tests/perf-view.client.spec.tsx @@ -89,6 +89,7 @@ function step(overrides: Partial = {}): PerfStepMetrics { toolOutput: 0, retries: 0, toolErrors: 0, + answer: null, cacheRead: null, cacheWrite: null, cacheHit: null, @@ -125,6 +126,7 @@ function turn(overrides: Partial = {}): PerfTurnMetrics { retries: 0, toolErrors: 0, toolOutput: 0, + answer: null, lastStepWall: 1_000, running: false, interrupted: false, @@ -715,6 +717,27 @@ describe('step rows', () => { expect(detailRow().textContent).toContain('done') }) + it('previews the answered text in the did cell and skips blank answers', () => { + mount(turnSnapshot([step({ answer: 'first line\nsecond line' })])) + const stepDid = cellsOf(stepRow(1))[2] as HTMLTableCellElement + expect(stepDid.textContent).toContain('first line') + expect(within(stepDid).getByTitle(/first line/).getAttribute('title')).toBe('first line\nsecond line') + + cleanup() + mount(turnSnapshot([step({ answer: ' \n ' })])) + expect(cellsOf(stepRow(1))[2]?.textContent).toBe(say('did.answer')) + + cleanup() + mount({ turns: [turn({ answer: 'turn answer text' })], stepCount: 0 }) + const turnDid = cellsOf(rowWithPrefix(say('turn.label', { turn: 1 })))[2] as HTMLTableCellElement + expect(turnDid.textContent).toContain('turn answer text') + expect(within(turnDid).getByTitle('turn answer text')).toBeTruthy() + + cleanup() + mount({ turns: [turn({ answer: ' ' })], stepCount: 0 }) + expect(cellsOf(rowWithPrefix(say('turn.label', { turn: 1 })))[2]?.textContent).toBe(say('did.answer')) + }) + it('shows context deltas and the gap since the previous step', () => { const steps = [ step({ step: 1, context: 1_000, toolOutput: 500, output: 100, startTime: BASE, endTime: BASE + 1_000 }),