From b3ea0d987ec63f55158edba2bfa2a5b69e958109 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 09:53:32 +0800 Subject: [PATCH] feat(ui): complete trajectory request inspection --- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 17 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 17 +- packages/client/runtime/src/client/index.ts | 3 +- .../src/client/sessions/conversation.ts | 63 ++ .../src/client/sessions/fold-adapter.ts | 2 + .../runtime/src/client/sessions/session.ts | 259 +++++++- packages/client/tsdown.client.ts | 2 + .../ui-layout/src/client/AppFrame.module.css | 37 +- .../ui-primitives/src/JsonTree.module.css | 10 +- .../src/markdown/CodeBlock.module.css | 15 + .../ui-primitives/src/markdown/CodeBlock.tsx | 6 +- .../ui-primitives/src/markdown/plain-text.ts | 7 +- packages/client/ui-trajectory/package.json | 3 + .../src/client/ContextsPanel.module.css | 116 ---- .../src/client/ContextsPanel.tsx | 84 --- .../src/client/TrajectoryCell.module.css | 5 + .../src/client/TrajectoryCell.tsx | 4 + .../src/client/TrajectoryTable.module.css | 295 +++++++-- .../src/client/TrajectoryTable.tsx | 587 +++++++++++++----- .../src/client/TrajectoryToolbar.module.css | 32 +- .../src/client/TrajectoryToolbar.tsx | 15 - .../src/client/TrajectoryView.tsx | 312 ++++++---- .../src/client/context-branches.ts | 123 ++++ .../client/ui-trajectory/src/client/layout.ts | 218 ++++++- .../src/client/trajectory-record.ts | 22 +- .../ui-trajectory/src/client/views.module.css | 10 +- packages/compact/compact-basic/src/region.ts | 7 +- .../compact/compact-basic/src/summarizer.ts | 13 +- packages/compact/compact/src/types.ts | 8 +- pnpm-lock.yaml | 4 + scripts/client-bundle-css.spec.ts | 47 ++ 32 files changed, 1692 insertions(+), 655 deletions(-) delete mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.module.css delete mode 100644 packages/client/ui-trajectory/src/client/ContextsPanel.tsx create mode 100644 packages/client/ui-trajectory/src/client/context-branches.ts create mode 100644 scripts/client-bundle-css.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 035cb14e90..9789c8b850 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: 30d1a2b0b43c8ca134f934c7197f85ff03c65742 -2026-07-27-trajectory-inspection-ledger.zh.md: 2c8724160ca25a4bd9ae4cdddd5b1e07bc1d808c +2026-07-27-trajectory-inspection-ledger.md: feefac9098a9ddc15b1d636ac9edc010655bd7a7 +2026-07-27-trajectory-inspection-ledger.zh.md: 50da33604a35be2eefe4d6cb4d7b4321427eae99 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index 30d1a2b0b4..feefac9098 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -12,12 +12,13 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** -- Turn boundaries are thick rules between record rows, while each Step appears as a compact inline marker on its first record. Individual User, Assistant, Tool, and Subtool events share stable columns for index, event kind, and content; token usage and duration stay in the inspector, a thin timeline rail preserves sequence, and nested subtools receive a small indentation. -- Product prose continues to use the existing sans stack. Record indexes, token counts, durations, group summaries, tool calls, and raw payloads use the existing code stack because they are machine data. -- Existing semantic theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; business blue is limited to Assistant identity, selection, links, and focus; warning is limited to running work; error is limited to failed work. User and Tool roles do not impersonate runtime states. -- Entity surfaces stay flat and separated by hairline borders. Shadow appears only when the inspector becomes an overlay at narrow widths. -- Selecting a record opens an inspector inside Trajectory with Overview, Input, Output, and Timing tabs. This state is deliberately independent from the conversation-wide Chat details column: it inspects a trajectory record without changing the user's Chat context. -- The three-column ledger reserves its width for record content. At narrow widths the inspector overlays the ledger and remains dismissible by keyboard or pointer. +- The ledger keeps session events in sequence within rewind-delimited branches. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests. +- Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector. +- Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack. +- Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. +- Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. +- Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. +- This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. ## Alternatives considered @@ -25,7 +26,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Keep one card per Turn and Step.** Rejected: repeated card chrome reduced the number of visible records and made cross-step comparison slower. -**Flatten every record without turn rules or step markers.** Rejected: a trajectory is not merely a log stream; Turn and Step boundaries are essential causal landmarks even when they do not consume dedicated rows. +**Flatten every record without Turn or Request boundaries.** Rejected: a trajectory is not merely a log stream; those boundaries preserve the causal structure without consuming dedicated rows. **Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. @@ -33,4 +34,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Step orientation. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payload and assistant timing. The inspector floats over the table only when a permanent split would make both panes unusable. Focused component tests pin the ledger, fold control, keyboard selection, payload tabs, timing facts, and running/error semantics; the assembled Web snapshot pins the real seeded session with the local inspector open. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. Focused component tests pin projection, folding, selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index 2c8724160c..50da33604a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -12,12 +12,13 @@ Status: implemented **使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** -- 轮次边界由记录行之间较粗的分割线表示,每个步骤在其首条记录上以紧凑的行内标记呈现。用户、助手、工具和子工具事件共用稳定的索引、事件类型和内容列;token 用量与耗时留在检查器中,细线时间轴保留事件顺序,嵌套子工具则采用小幅缩进。 -- 产品正文继续使用现有无衬线字体栈。记录索引、token 数、耗时、分组摘要、工具调用和原始载荷属于机器数据,因此使用现有代码字体栈。 -- 现有语义主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;业务蓝色仅用于助手身份、选择状态、链接和焦点;警告色仅用于运行中的工作;错误色仅用于失败的工作。用户和工具角色不借用运行时状态的视觉语义。 -- 各记录表面保持平面化,并以细线边框分隔。只有在窄屏下检查器变为浮层时才使用阴影。 -- 选择记录后,轨迹视图内部会打开包含概览、输入、输出和计时标签页的检查器。该状态有意与会话级 Chat 详情栏相互独立:检查轨迹记录不会改变用户在 Chat 中的上下文。 -- 三列记录表将宽度留给记录内容。在窄屏下,检查器会覆盖在记录表上,并且仍可通过键盘或指针关闭。 +- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 +- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 +- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 +- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 +- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 +- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 +- 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 ## 曾考虑的替代方案 @@ -25,7 +26,7 @@ Status: implemented **每个轮次和步骤保留一张卡片。** 不予采纳:重复的卡片框架减少了可见记录数量,并降低了跨步骤比较的速度。 -**不使用轮次分割线与步骤标记,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;即使轮次与步骤边界不再占用独立行,它们仍是不可缺少的关键因果标记。 +**不使用轮次或请求边界,将所有记录完全扁平化。** 不予采纳:轨迹并非普通日志流;这些边界无需占用独立行,也能保留因果结构。 **复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 @@ -33,4 +34,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与步骤定位的同时,每个视口可以显示更多有效记录。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器则将这些数据与完整载荷、助手计时一并展示。只有固定分栏会让两个面板都无法使用时,检查器才浮在表格之上。针对性组件测试锁定事件记录表、折叠控制、键盘选择、载荷标签页、计时数据和运行/错误语义;组装后的 Web 快照则锁定真实预置会话在局部检查器打开时的渲染结果。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。针对性组件测试锁定投影、折叠、选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表与检查器。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b096a20697..57a1d09aab 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -30,7 +30,8 @@ export type { export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, - ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot, + CompactionRequestView, ConversationContext, ConversationContextOriginKind, + ConversationNode, ConversationPromptChange, ConversationPromptSnapshot, ModelRequestView, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 2421fe56f4..1deb456913 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -51,6 +51,7 @@ export interface UserMessageNode { time: number content: readonly ContentBlock[] source: unknown + meta?: unknown } /** Recorded boundaries used to derive assistant latency and throughput. */ @@ -67,6 +68,7 @@ export interface AssistantTiming { export interface AssistantRequestConfig { provider: string model: string + purpose?: string thinking?: string reasoningEffort?: string temperature?: number @@ -108,6 +110,7 @@ export interface SteeringMessageNode { turn: number content: readonly ContentBlock[] source: unknown + meta?: unknown } /** A context/system injection surfaced in the flow. */ @@ -241,6 +244,20 @@ export interface ConversationPromptSnapshot { tools: readonly ToolSchema[] } +/** One system-prompt/tool-catalog state that became effective in the request timeline. */ +export interface ConversationPromptChange { + /** Sequence of the request/header event that introduced this state. */ + seq: number + /** Unix epoch ms from the request/header event. */ + time: number + /** How the model-visible system configuration differs from the prior recorded state. */ + kind: 'initial' | 'system' | 'tools' | 'system-and-tools' + /** Complete state effective from this event onward. */ + prompt: ConversationPromptSnapshot + /** State immediately before this change; absent for the initial header. */ + previous?: ConversationPromptSnapshot +} + /** One immutable model-context generation reconstructed from surface replacements. */ export interface ConversationContext { /** Zero-based generation within the session; stable across later appends. */ @@ -259,6 +276,46 @@ export interface ConversationContext { nodes: readonly ConversationNode[] } +/** One auxiliary compaction model request reconstructed from its durable lifecycle events. */ +export interface CompactionRequestView { + startSeq: number + turn: number + startedAt: number + completedAt: number | null + status: 'running' | 'complete' | 'error' + error?: string + summarySeq?: number + replacementSeq?: number + summary?: readonly ContentBlock[] + rawOutput?: readonly ContentBlock[] + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig + usage?: unknown +} + +/** One ordinary provider-request attempt reconstructed from a durable step boundary. */ +export interface ModelRequestView { + /** Sequence of the step/start event that opened this attempt. */ + startSeq: number + turn: number + step: number + /** Unix epoch ms from step/start. */ + startedAt: number + /** Assistant completion time, or the failed step/end time when no response completed. */ + completedAt: number | null + status: 'running' | 'complete' | 'error' + error?: string + /** Assistant/message sequence when this attempt completed successfully. */ + resultSeq?: number + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig + usage?: unknown + /** Retry ordinal scheduled after this failed attempt. */ + retry?: number + maxRetries?: number + retryDelayMs?: number +} + /** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */ export interface PromptError { op: 'send' | 'stop' @@ -272,6 +329,12 @@ export interface ConversationSnapshot { nodes: readonly ConversationNode[] /** Append-only context generations split at every model-surface replacement. */ contexts?: readonly ConversationContext[] + /** Auxiliary compaction requests, including those without an assistant/message surface node. */ + compactionRequests?: readonly CompactionRequestView[] + /** Ordinary provider requests, including failed attempts that produced no assistant message. */ + requestAttempts?: readonly ModelRequestView[] + /** System-prompt/tool-catalog changes in request order. */ + promptChanges?: readonly ConversationPromptChange[] /** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */ foldDegraded: boolean partial: PartialAssistant | null diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 6f88c678e7..9f49936e10 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -59,6 +59,7 @@ function materializeNode( return { kind: 'user', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, + meta: event.data.meta, } case 'assistant/message': return { @@ -76,6 +77,7 @@ function materializeNode( return { kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, content: event.data.content, source: event.data.source, + meta: event.data.meta, } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 28acf5a859..cc79727f69 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -12,8 +12,9 @@ import type { import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { ObservableSnapshot } from '../contract/store.ts' import type { - CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, - PromptError, QueuedMessage, RunningToolCall, + CodeSubCall, CompactionRequestView, ComposerPhase, ConversationNode, + ConversationPromptChange, ConversationPromptSnapshot, ConversationSnapshot, + ModelRequestView, OpenState, PromptError, QueuedMessage, RunningToolCall, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' @@ -113,6 +114,11 @@ export class Session implements ObservableSnapshot { private callSchemas = new Map() private callSchemasRev = 0 private callSchemasCache: { rev: number; value: ReadonlyMap } | null = null + private promptChangesRev = 0 + private promptChangesCache: { + rev: number + value: readonly ConversationPromptChange[] + } | null = null private running = false /** * Sticky send marker, private input of the composerPhase derivation: set @@ -671,6 +677,7 @@ export class Session implements ObservableSnapshot { } switch (event.type) { case 'request/header': { + this.promptChangesRev++ this.activeToolSchemas = new Map( (event.data.header.tools ?? []).map(schema => [schema.name, schema]), ) @@ -776,6 +783,7 @@ export class Session implements ObservableSnapshot { this.activeToolSchemas = new Map() this.callSchemas = new Map() this.callSchemasRev++ + this.promptChangesRev++ for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -818,11 +826,23 @@ export class Session implements ObservableSnapshot { if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) } } + if ( + this.promptChangesCache === null + || this.promptChangesCache.rev !== this.promptChangesRev + ) { + this.promptChangesCache = { + rev: this.promptChangesRev, + value: derivePromptChanges(this.events), + } + } const partial = this.partial?.toPartial() ?? null return { sessionId: this.sessionId, nodes, contexts, + compactionRequests: deriveCompactionRequests(this.events), + requestAttempts: deriveModelRequests(this.events), + promptChanges: this.promptChangesCache.value, foldDegraded: degraded, partial, runningCalls: this.callsCache.value, @@ -862,3 +882,238 @@ function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPha if (hasContent) return 'active' return promptAttempted ? 'engaging' : 'blank' } + +interface RetryEvent { + type: 'llm/retry' + seq: number + time: number + data: { + turn: number + step: number + retry: number + maxRetries: number + delayMs: number + failure: { message: string } + } +} + +function modelRequestKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +/** Project every durable step into one provider request, retaining failed retry attempts. */ +function deriveModelRequests(events: readonly SessionEvent[]): readonly ModelRequestView[] { + const requests: ModelRequestView[] = [] + const byStep = new Map() + let activeStep: string | undefined + let activeConfig: ConversationPromptSnapshot['config'] + + const update = (key: string, change: Partial): void => { + const index = byStep.get(key) + if (index === undefined) return + const request = requests[index] + if (request !== undefined) requests[index] = { ...request, ...change } + } + + for (const sourceEvent of events) { + if (sourceEvent.type === 'request/header') { + activeConfig = sourceEvent.data.header.config + if (activeStep !== undefined) update(activeStep, { requestConfig: activeConfig }) + continue + } + if (sourceEvent.type === 'step/start') { + const { turn, step } = sourceEvent.data + const key = modelRequestKey(turn, step) + byStep.set(key, requests.length) + requests.push({ + startSeq: sourceEvent.seq, + turn, + step, + startedAt: sourceEvent.time, + completedAt: null, + status: 'running', + ...(activeConfig === undefined ? {} : { requestConfig: activeConfig }), + }) + activeStep = key + continue + } + if (sourceEvent.type === 'assistant/message') { + const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step) + update(key, { + completedAt: sourceEvent.time, + status: 'complete', + resultSeq: sourceEvent.seq, + provenance: { + provider: sourceEvent.data.provenance.provider, + model: sourceEvent.data.provenance.model, + }, + ...(sourceEvent.data.usage === undefined ? {} : { usage: sourceEvent.data.usage }), + }) + continue + } + if (sourceEvent.type === 'step/end') { + const key = modelRequestKey(sourceEvent.data.turn, sourceEvent.data.step) + const index = byStep.get(key) + const request = index === undefined ? undefined : requests[index] + if (request !== undefined && request.status === 'running') { + requests[index] = { + ...request, + completedAt: sourceEvent.time, + status: 'error', + } + } + if (activeStep === key) activeStep = undefined + continue + } + if ((sourceEvent.type as string) === 'llm/retry') { + const event = sourceEvent as unknown as RetryEvent + update(modelRequestKey(event.data.turn, event.data.step), { + status: 'error', + error: event.data.failure.message, + retry: event.data.retry, + maxRetries: event.data.maxRetries, + retryDelayMs: event.data.delayMs, + }) + continue + } + if (sourceEvent.type !== 'turn/end' || sourceEvent.data.reason.kind !== 'error') continue + const reason = sourceEvent.data.reason + update(modelRequestKey(sourceEvent.data.turn, reason.step), { + status: 'error', + error: 'failure' in reason ? reason.failure.message : reason.message, + }) + } + return requests +} + +interface CompactionStartEvent { + type: 'compact/start' + seq: number + time: number + data: { turn: number } +} + +interface CompactionSummaryEvent { + type: 'compact/summary' + seq: number + time: number + data: { + summary: readonly ContentBlock[] + rawOutput?: readonly ContentBlock[] + provider: string + model: string + maxTokens?: number + usage?: unknown + } +} + +interface CompactionEndEvent { + type: 'compact/end' + seq: number + time: number + data: { turn: number; error?: string } +} + +/** Project log-only compaction request brackets without coupling the client runtime to one backend package. */ +function deriveCompactionRequests(events: readonly SessionEvent[]): readonly CompactionRequestView[] { + const requests: CompactionRequestView[] = [] + let active: CompactionRequestView | undefined + for (const sourceEvent of events) { + const type = sourceEvent.type as string + if (type === 'compact/start') { + const event = sourceEvent as unknown as CompactionStartEvent + active = { + startSeq: event.seq, + turn: event.data.turn, + startedAt: event.time, + completedAt: null, + status: 'running', + } + continue + } + if (type === 'compact/summary' && active !== undefined) { + const event = sourceEvent as unknown as CompactionSummaryEvent + active = { + ...active, + summarySeq: event.seq, + summary: event.data.summary, + ...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }), + provenance: { + provider: event.data.provider, + model: event.data.model, + }, + requestConfig: { + provider: event.data.provider, + model: event.data.model, + purpose: 'compaction', + ...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }), + }, + ...(event.data.usage === undefined ? {} : { usage: event.data.usage }), + } + continue + } + if ( + sourceEvent.type === 'user/message' + && active?.summarySeq !== undefined + && isCompactionSource(sourceEvent.data.source) + ) { + active = { ...active, replacementSeq: sourceEvent.seq } + continue + } + if (type !== 'compact/end' || active === undefined) continue + const event = sourceEvent as unknown as CompactionEndEvent + active = { + ...active, + completedAt: event.time, + status: event.data.error === undefined ? 'complete' : 'error', + ...(event.data.error === undefined ? {} : { error: event.data.error }), + } + requests.push(active) + active = undefined + } + if (active !== undefined) requests.push(active) + return requests +} + +/** Project request headers into model-visible system/tool changes only. */ +function derivePromptChanges(events: readonly SessionEvent[]): readonly ConversationPromptChange[] { + const changes: ConversationPromptChange[] = [] + let previous: ConversationPromptSnapshot | undefined + for (const event of events) { + if (event.type !== 'request/header') continue + const prompt: ConversationPromptSnapshot = { + config: event.data.header.config, + system: event.data.header.system ?? '', + tools: event.data.header.tools ?? [], + } + const systemChanged = previous !== undefined && previous.system !== prompt.system + const toolsChanged = previous !== undefined + && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) + if (previous === undefined || systemChanged || toolsChanged) { + changes.push({ + seq: event.seq, + time: event.time, + kind: previous === undefined + ? 'initial' + : systemChanged && toolsChanged + ? 'system-and-tools' + : systemChanged + ? 'system' + : 'tools', + prompt, + ...(previous === undefined ? {} : { previous }), + }) + } + previous = prompt + } + return changes +} + +function isCompactionSource(source: unknown): boolean { + return typeof source === 'object' + && source !== null + && 'kind' in source + && source.kind === 'plugin' + && 'plugin' in source + && source.plugin === 'compact' +} diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b93feae8b..bf560d4d53 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -6,6 +6,7 @@ * lightningcss inside the bundle: importing `x.module.css` yields the * hashed class map, and the css text auto-injects a