From 9ca0241d5d113d4e6d575219df4550f9c0f39ddb Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 12:10:48 +0800 Subject: [PATCH 01/47] feat(web): add durable session metrics (round 1) --- ...8-host-owned-web-session-metrics.i18n.yaml | 6 + ...26-07-28-host-owned-web-session-metrics.md | 35 +++ ...07-28-host-owned-web-session-metrics.zh.md | 35 +++ .../snapshots/fresh-round-trip/ui.expected.md | 16 +- docs/event-producer-consumer.md | 2 +- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/index.ts | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/sessions/conversation.ts | 8 +- .../runtime/src/client/sessions/session.ts | 54 +++- packages/client/runtime/tests/fake-api.ts | 9 +- .../client/runtime/tests/queue-store.spec.ts | 7 + packages/client/runtime/tests/session.spec.ts | 112 ++++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/chat/StatsLine.tsx | 104 ++++--- .../tests/chat-branch-tails.spec.tsx | 14 +- .../tests/chat-code-subcalls.spec.tsx | 2 +- .../tests/chat-stats-bash-sample.spec.tsx | 99 ++++++- .../tests/chat-toolview-slot.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 16 +- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/queue-dock.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 74 ++++- .../host/apiproxy/src/api/events.schema.ts | 5 +- packages/host/apiproxy/src/api/events.ts | 2 + packages/host/apiproxy/src/api/index.ts | 2 +- .../host/apiproxy/src/api/sessions.schema.ts | 15 +- packages/host/apiproxy/src/api/sessions.ts | 29 +- packages/host/apiproxy/src/session-metrics.ts | 194 ++++++++++++ .../apiproxy/tests/api-proxy-models.spec.ts | 34 +++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 43 ++- .../apiproxy/tests/session-metrics.spec.ts | 277 ++++++++++++++++++ 43 files changed, 1139 insertions(+), 97 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md create mode 100644 packages/host/apiproxy/src/session-metrics.ts create mode 100644 packages/host/apiproxy/tests/session-metrics.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml new file mode 100644 index 0000000000..95add14bf5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/architecture/2026-07-28-host-owned-web-session-metrics.md +2026-07-28-host-owned-web-session-metrics.md: 04381c7443491fd9de101a87713fa5c183800d0e +2026-07-28-host-owned-web-session-metrics.zh.md: 6ad06ea61508d3f0703c19bc7c9f14969f119334 diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md new file mode 100644 index 0000000000..04381c7443 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md @@ -0,0 +1,35 @@ +# Agent Note: Host-owned Web session metrics + +Status: implemented + +English | [中文](2026-07-28-host-owned-web-session-metrics.zh.md) + +## Problem + +A Web stats line derived from the currently loaded conversation nodes is window-dependent under pagination. Compaction can replace visible content without preserving historical usage, and route changes leave the browser without an authoritative context capacity. Cache-write tokens also risk being folded into a cache-hit formula whose denominator has different semantics. + +## Decision + +The Host owns one session-level metrics projection. It incrementally folds the complete durable event log, keys settled usage by `(turn, step)`, and replaces an earlier usage record for the same key instead of double-counting chunk and message forms. Uncached input, output, cache reads, and cache writes remain four disjoint cumulative buckets. Compaction can change the current prompt surface without erasing historical usage. + +Current context pressure is a separate point-in-time value from `tokenMeter.measure(session).totalTokens`. Capacity comes only from `llm.resolveModelInfo(provider, model).context.contextWindow` for the agent's selected route. A route change immediately publishes metrics with capacity absent, then publishes the resolved capacity behind a route generation fence; stale metadata cannot label the new route. + +The tail `session.history` response carries the projection, while older pages omit it. Live changes use `session/metrics` mux frames. Both forms carry a durable-log revision and a projection revision; the client accepts only nondecreasing revisions, preserves metrics across older-page prepend, and clears them at a new subscription baseline. Missing measurement or metadata stays absent. + +The Web stats line treats the projection as its sole token source. It renders uncached input, output, and cache reads separately, computes cache hit as `cacheRead / (uncachedInput + cacheRead)`, and shows current context as a percentage of the exact route capacity. Cache writes never enter that percentage. Visible nodes continue to supply only turn and step counts. + +## Alternatives considered + +**Fold the loaded node window in React.** This cannot survive pagination or compaction and duplicates durable-log semantics in a presentation package. + +**Send usage only with raw assistant events.** Reconnect and older-page stitching would still need the client to reconstruct a full-log aggregate, and duplicate usage forms would need protocol-specific repair there. + +**Reuse one total-token field for cache hit.** Cache reads, cache writes, and uncached input represent distinct provider accounting buckets; combining them would make the displayed rate misleading. + +**Keep the previous capacity until the new route resolves.** The old number would temporarily claim the wrong selected model. An explicit unknown state is honest and generation-safe. + +## Consequences + +Token totals remain stable across pagination, replay, compaction, and browser reconnect. The client stores a small detached projection instead of scanning the conversation window, and the status row remains readable for large histories through compact number formatting. + +The Host performs one incremental log fold per session and schedules live projection updates only for usage, request-header, or surface-changing events; text and reasoning deltas do not publish metrics. Exact capacity resolution is asynchronous and may briefly render as unknown. Deployments without a token meter or model context metadata retain the row and label the unavailable value instead of fabricating one. diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md new file mode 100644 index 0000000000..6ad06ea615 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Host 拥有的 Web 会话指标 + +Status: implemented + +[English](2026-07-28-host-owned-web-session-metrics.md) | 中文 + +## 问题 + +Web 统计行若根据当前加载的会话节点推导指标,其结果会随分页窗口变化。压缩(compaction)可以替换可见内容,却无法保留历史用量;路由变更会让浏览器缺少权威的上下文容量。缓存写入 token 还可能被计入缓存命中率公式,而该公式的分母具有不同语义。 + +## 决策 + +Host 拥有一项会话级指标投影。它以增量方式归并完整的持久事件日志,按 `(turn, step)` 标识已结算用量;同一标识再次出现时,会替换较早的用量记录,而不会重复统计分片和消息两种形态。未缓存输入、输出、缓存读取与缓存写入保持为四个彼此独立的累计计数项。压缩可以改变当前提示词表层,但不会抹除历史用量。 + +当前上下文压力是一个独立的即时值,取自 `tokenMeter.measure(session).totalTokens`。容量仅来自 `llm.resolveModelInfo(provider, model).context.contextWindow`,并对应 agent(智能体)所选的路由。路由变更时,Host 会立即发布不带容量的指标,再通过路由代际围栏发布解析出的容量;陈旧元数据无法标记新的路由。 + +`session.history` 尾页响应携带该投影,较早页面则省略它。实时变更使用 `session/metrics` mux 帧。两种形式都携带持久日志修订号和投影修订号;客户端只接受不减小的修订号,在向前加载较早页面时保留指标,并在建立新的订阅基线时将其清除。测量值或元数据缺失时,对应字段保持缺失。 + +Web 统计行把该投影视为唯一的 token 数据来源。它分别呈现未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率,并把当前上下文显示为精确路由容量的百分比。缓存写入绝不计入缓存命中率。可见节点仍然只提供轮次和步骤计数。 + +## 备选方案 + +**在 React 中归并已加载的节点窗口。** 此方案无法跨越分页或压缩保留数据,还会在展示包中重复实现持久日志语义。 + +**只随原始 assistant 事件发送用量。** 重连和较早页面拼接仍会要求客户端重建完整日志聚合,而且重复的用量形态需要在客户端按协议专门修复。 + +**为缓存命中率复用单一的 token 总数字段。** 缓存读取、缓存写入与未缓存输入是提供方记账中的不同计数项;将它们合并会使显示的比率产生误导。 + +**在新路由解析完成前保留旧容量。** 旧数值会在短时间内错误标示所选模型。显式的「未知」状态能如实反映情况,并避免跨代串扰。 + +## 后果 + +token 总量在分页、回放、压缩和浏览器重连期间保持稳定。客户端存储一项小型脱耦投影,无需扫描会话窗口;状态行采用紧凑数字格式,因此在较长的历史记录中仍然清晰易读。 + +Host 为每个会话执行一次增量日志归并,仅为用量事件、请求头事件或表层变更事件调度实时投影更新;文本与推理(reasoning)增量不会发布指标。精确容量解析为异步操作,因此可能短暂显示「未知」。未部署 token 计量器或缺少模型上下文元数据时,系统仍保留该行,并标示不可用的值,而不会虚构数据。 diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index dc572023fd..29d039fd53 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -7,22 +7,30 @@ - tab "Trajectory" - tab "Waterfall" - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop." +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- button "▸ 上下文注入" - button "Think The user wants me to run a simple bash command and reply with \"DONE\".": - img - text: Think The user wants me to run a simple bash command and reply with "DONE". -- text: Echo the test string +- img +- text: Bash Echo the test string - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": - img - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE". - paragraph: DONE -- text: cache hit 99% · 15,818 tokens · 1 turns · 2 steps +- text: 219 uncached input · 111 output · 15.5k cache read · cache hit 99% · context 6% of 128k · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img - combobox "Access mode": - option "Read-only" [selected] - option "Read-write" -- button "选择模型,当前 deepseek-v4-flash": - - text: deepseek-v4-flash +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 18ca96e1b3..122935aea9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,7 +9,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:140`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:423`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index a1380c4b58..497c79399e 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -11,7 +11,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionModels, + ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 0e50d8617f..a8d09ac47c 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -16,7 +16,7 @@ export type { ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionModels, + ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3fa9c934f3..3a80af5265 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.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 packages/client/runtime/README.md -README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499 -README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200 +README.md: fbb3a142b9efa50045f127d430bd2be2849f01f2 +README.zh.md: b5bd3c458ed462e01dfae5bcd7399dde5552dc8d diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 81261945cb..fbb3a142b9 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries two Host-owned full-log projections. `todos` comes from the tail history page, survives older-page prepend, and follows live `todo/write` events. `metrics` comes from tail history and live `session/metrics` frames, survives older-page prepend, and accepts only nondecreasing log and projection revisions; a subscription baseline clears it before replay so a new stream generation can restart revisions safely. Missing metrics remain `null` rather than being inferred from the visible node window. ## Workspace and Session lists diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index cbbf6eded4..b5bd3c458e 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值(host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`,因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带两项由 Host 拥有的完整日志投影。`todos` 来自 history 尾页,在向前加载较早页面时保留,并随实时 `todo/write` 事件更新。`metrics` 来自 history 尾页和实时 `session/metrics` 帧,在向前加载较早页面时保留,并且只接受日志修订号与投影修订号均不减小的数据;订阅基线会在回放前将其清除,使新的流代次可以安全地从头开始计数修订号。缺失的 metrics 保持为 `null`,而不是根据可见节点窗口推断。 ## Workspace 与 Session 列表 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 8cd57c4eb3..202ddf7df2 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -6,7 +6,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { - RpcError, SessionId, ToolCallView, ToolResultView, + RpcError, SessionId, SessionMetrics, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' @@ -246,4 +246,10 @@ export interface ConversationSnapshot { /** Current whole-list `todo/write` projection — the tail page's full-log value, then each live * write (last write wins); empty = the log holds no plan. */ todos: readonly TodoItem[] + /** + * Host-owned cumulative usage and current-context projection. Independent + * of `nodes` pagination; null until a tail response or live metrics frame + * supplies a current value. + */ + metrics: SessionMetrics | null } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 1dd0283429..a8eecd1bac 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, - SessionId, ToolEventView, + SessionId, SessionMetrics, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. @@ -102,6 +102,8 @@ export class Session implements ObservableSnapshot { /** Current whole-list todo/write projection: each tail history response replaces it (an omitted * field is the authoritative empty list) and every live write overwrites it. */ private todos: readonly TodoItem[] = [] + /** Host-owned metrics projection; ordering resets on each subscribed baseline. */ + private metrics: SessionMetrics | null = null /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends * copy-on-write the per-parent array so published snapshot references never mutate. */ private codeDispatches = new Map() @@ -296,6 +298,7 @@ export class Session implements ObservableSnapshot { this.events = [] this.views = [] this.baseSeq = 0 + this.metrics = null // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. this.pending.clear() @@ -364,6 +367,14 @@ export class Session implements ObservableSnapshot { this.queueRev++ this.notifier.markDirty() } + if (this.metrics !== null) { + this.metrics = null + this.notifier.markDirty() + } + return + } + case 'session/metrics': { + this.installMetrics(frame.metrics) return } case 'approval/requested': { @@ -482,13 +493,20 @@ export class Session implements ObservableSnapshot { this.openError = result.error return } - this.installWindow(result.value.events, result.value.hasMore, result.value.todos) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos, result.value.metrics) // Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more. const tailSeq = this.windowTailSeq() if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) { result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result if (generation !== this.openGeneration) return - if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos) + if (result.ok) { + this.installWindow( + result.value.events, + result.value.hasMore, + result.value.todos, + result.value.metrics, + ) + } } this.openState = 'open' } catch (error) { @@ -506,7 +524,12 @@ export class Session implements ObservableSnapshot { * Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here * (doOpen flips it after install), so recursing would push every buffered event straight * back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */ - private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void { + private installWindow( + entries: HistoryEntry[], + hasMore: boolean, + todos: readonly TodoItem[] | undefined, + metrics: SessionMetrics | undefined, + ): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) this.baseSeq = this.events[0]?.seq ?? 0 @@ -519,6 +542,7 @@ export class Session implements ObservableSnapshot { // field is the authoritative empty list, not a missing carrier. Assigning // it clears a plan the log never kept (a write lost to a host crash). this.todos = todos ?? [] + if (metrics !== undefined) this.installMetrics(metrics) this.foldAdapter.reset(this.events, this.baseSeq, this.views) this.rebuildDerivedFromWindow() const buffered = this.liveBuffer @@ -569,7 +593,12 @@ export class Session implements ObservableSnapshot { const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES }) // Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself. if (result.ok && generation === this.openGeneration && this.openState === 'open') { - this.installWindow(result.value.events, result.value.hasMore, result.value.todos) + this.installWindow( + result.value.events, + result.value.hasMore, + result.value.todos, + result.value.metrics, + ) } } catch (error) { console.error('[web-runtime] gap repair failed:', error) @@ -761,6 +790,20 @@ export class Session implements ObservableSnapshot { return tail === undefined ? null : tail.seq } + /** Install a metrics snapshot unless a newer durable or publication revision already landed. */ + private installMetrics(metrics: SessionMetrics): void { + const current = this.metrics + if ( + current !== null + && ( + metrics.logRevision < current.logRevision + || metrics.projectionRevision < current.projectionRevision + ) + ) return + this.metrics = metrics + this.notifier.markDirty() + } + private buildSnapshot(): ConversationSnapshot { const { nodes: folded, degraded } = this.foldAdapter.nodes() // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order. @@ -811,6 +854,7 @@ export class Session implements ObservableSnapshot { blank: this.blankBit, lastAgentError: this.lastAgentError, todos: this.todos, + metrics: this.metrics, } } } diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index a5eecd0cf5..6676f00190 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame, - RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry, + RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionMetrics, SessionModels, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -63,7 +63,12 @@ export class FakeApiClient implements IApiClient { onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' } onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 6fe9f33c80..0ecd7a5375 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -85,6 +85,13 @@ describe('queue retirement (host queuedMirror rules)', () => { expect(session.getSnapshot().queue).toHaveLength(1) }) + it('an unrelated durable event leaves the queue unchanged', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1')) + session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: ev.user(0, 'unrelated') }) + expect(session.getSnapshot().queue.map(row => row.key)).toEqual(['p-1']) + }) + it('steering/message drains the source-matched steering row only', () => { const session = makeSession() session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 100c6f524f..6d3ce893c7 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -7,8 +7,9 @@ */ import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId, SessionMetrics } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { entries, ev, plainTurn } from './event-script.ts' @@ -22,9 +23,37 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: return { api, session: new Session(SID, api) } } -function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) { +function histResponse( + events: SessionEvent[], + hasMore = false, + todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[], + metrics?: SessionMetrics, +) { // history now returns HistoryEntry[] ({event, view?}); these tests are view-less. - return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } })) + return Promise.resolve(ok({ + events: entries(events) as never[], + hasMore, + ...todos === undefined ? {} : { todos }, + ...metrics === undefined ? {} : { metrics }, + })) +} + +function metrics( + projectionRevision: number, + logRevision: number, + over: Partial = {}, +): SessionMetrics { + return { + projectionRevision, + logRevision, + uncachedInputTokens: 10, + outputTokens: 4, + cacheReadTokens: 90, + cacheWriteTokens: 3, + contextTokens: 35, + contextWindow: 100, + ...over, + } } describe('open', () => { @@ -40,6 +69,19 @@ describe('open', () => { expect(snapshot.openState).toBe('open') expect(snapshot.hasMore).toBe(true) expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant']) + expect(snapshot.metrics).toBeNull() + }) + + it('installs full-log metrics independently of older history pages', async () => { + const { api, session } = makeSession() + const tailMetrics = metrics(4, 106) + api.onHistory = () => histResponse(plainTurn(100, 3, '问', '答'), true, undefined, tailMetrics) + await session.open() + expect(session.getSnapshot().metrics).toBe(tailMetrics) + + api.onHistory = () => histResponse(plainTurn(94, 2, '旧问', '旧答')) + await session.loadOlder() + expect(session.getSnapshot().metrics).toBe(tailMetrics) }) it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => { @@ -104,6 +146,43 @@ describe('live event path', () => { expect(session.getSnapshot().nodes).toEqual(before.nodes) }) + it('orders live metrics, rejects stale projections, and clears the value at a reconnect baseline', async () => { + const { session } = await opened() + const current = metrics(8, 10) + session.handleMuxEnvelope('m1' as never, { + type: 'session/metrics', + sessionId: SID, + metrics: current, + }) + expect(session.getSnapshot().metrics).toBe(current) + + session.handleMuxEnvelope('m2' as never, { + type: 'session/metrics', + sessionId: SID, + metrics: metrics(9, 9, { uncachedInputTokens: 1 }), + }) + session.handleMuxEnvelope('m3' as never, { + type: 'session/metrics', + sessionId: SID, + metrics: metrics(7, 11, { uncachedInputTokens: 2 }), + }) + expect(session.getSnapshot().metrics).toBe(current) + + session.handleMuxEnvelope('sub' as never, { + type: 'session/subscribed', + sessionId: SID, + lastSeq: 5, + }) + expect(session.getSnapshot().metrics).toBeNull() + const nextGeneration = metrics(0, 10, { contextTokens: 20 }) + session.handleMuxEnvelope('m4' as never, { + type: 'session/metrics', + sessionId: SID, + metrics: nextGeneration, + }) + expect(session.getSnapshot().metrics).toBe(nextGeneration) + }) + it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } @@ -332,6 +411,23 @@ describe('prompt and cancel errors', () => { }) describe('pending interactions', () => { + it('routes an approval wait response through the original requested rpcId', async () => { + const { api, session } = makeSession() + session.handleMuxEnvelope('ra-answer' as never, { + type: 'approval/requested', + sessionId: SID, + approvalId: 'ap-answer' as never, + toolName: 'bash', + }) + const wait = session.getSnapshot().pending[0]! + await wait.respond({ ok: true, value: { decision: 'allow' } }) + expect(api.callsOf('respond')).toEqual([{ + type: 'client-response', + rpcId: 'ra-answer', + result: { ok: true, value: { decision: 'allow' } }, + }]) + }) + it('adds approval/question on requested and removes them on resolved', async () => { const { session } = makeSession() session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' }) @@ -374,6 +470,16 @@ describe('pending interactions', () => { }) describe('remaining branches', () => { + it('rejects a second scope bind and allows rebinding after explicit release', () => { + const { session } = makeSession() + const first = new Context() + const second = new Context() + session.bindScope(first) + expect(() => { session.bindScope(second) }).toThrow(`session ${SID} already has a bound scope`) + session.unbindScope() + expect(() => { session.bindScope(second) }).not.toThrow() + }) + it('prompt transport throw folds to internal promptError', async () => { const { api, session } = makeSession() api.onPrompt = () => Promise.reject(new Error('prompt wire down')) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index c1a6828d5b..dfc109ce90 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: 56a445ccfa86e0b11cf5aefc37819a30746f0739 -README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070 +README.md: 9a8595e693e2b49691d0d130d4db0754e1e4829b +README.zh.md: f5080314488e807877ebf0c93ea82cdd9725e8ed diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 56a445ccfa..9a8595e693 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,6 +18,8 @@ Per-session UI state for selection and the active view lives in the declared cha The composer bar declares session-scoped single seats for `'conversation.input.plan'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. +The chat stats line reads durable token counters and current-context pressure only from `ConversationSnapshot.metrics`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy against the selected route's exact capacity. Missing host data is labeled unknown, never reconstructed from a paged window. + `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a7c160ecdd..f508031448 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -18,6 +18,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 输入栏为 `'conversation.input.plan'` 和 `'conversation.input.model'` 声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送/停止按钮之前。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 +聊天统计行只从 `ConversationSnapshot.metrics` 读取持久的 token 计数与当前上下文压力;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并根据所选路由的精确容量显示上下文占用率。Host 数据缺失时标为「未知」,绝不根据分页窗口重建。 + `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 45b783f19b..5c77b585e7 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -5,48 +5,63 @@ import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/clien import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import css from './StatsLine.module.css' -interface UsageTotals { +type SessionMetrics = NonNullable + +interface VisibleCounts { turns: number steps: number - tokens: number - cacheHitPct: number | null -} - -/** Token accounting slice of assistant `usage` (typed upstream as unknown). */ -interface UsageLike { - inputTokens?: number - outputTokens?: number - cacheReadTokens?: number } /** - * Fold assistant nodes into display totals. + * Count visible assistant turns and steps without treating the paged window + * as an accounting source. * @param nodes - snapshot nodes. - * @returns totals; cacheHitPct null until any cache accounting arrives. + * @returns visible turn and step counts. */ -export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals { +export function deriveVisibleCounts(nodes: ConversationSnapshot['nodes']): VisibleCounts { const turns = new Set() let steps = 0 - let tokens = 0 - let input = 0 - let cacheRead = 0 for (const node of nodes) { if (node.kind !== 'assistant') continue turns.add(node.turn) steps += 1 - const usage = node.usage as UsageLike | undefined - if (usage === undefined) continue - input += usage.inputTokens ?? 0 - cacheRead += usage.cacheReadTokens ?? 0 - tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0) - } - const denom = input + cacheRead - return { - turns: turns.size, - steps, - tokens, - cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100), } + return { turns: turns.size, steps } +} + +/** + * Format large token values with the status surfaces' compact suffix style. + * @param value - token count or model capacity. + * @returns locale-formatted count. + */ +export function formatMetricTokens(value: number): string { + if (value < 1_000) return value.toLocaleString('en-US') + return value.toLocaleString('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }).replace('K', 'k').replace('M', 'm').replace('B', 'b') +} + +/** + * Existing Web cache-hit formula over disjoint uncached and cache-read input. + * @param metrics - Host-owned durable usage. + * @returns rounded integer percent, or null when no input was billed. + */ +export function cacheHitPercent(metrics: SessionMetrics): number | null { + const denominator = metrics.uncachedInputTokens + metrics.cacheReadTokens + return denominator === 0 + ? null + : Math.round(metrics.cacheReadTokens / denominator * 100) +} + +/** + * Current context occupancy using the TUI's integer rounding and upper clamp. + * @param metrics - Host-owned current pressure and exact route capacity. + * @returns occupancy percent, or null when either input is unavailable. + */ +export function contextPercent(metrics: SessionMetrics): number | null { + if (metrics.contextTokens === undefined || metrics.contextWindow === undefined) return null + return Math.min(100, Math.round(metrics.contextTokens / metrics.contextWindow * 100)) } /** Props: the conversation-snapshot selector hook (handed down by ChatView). */ @@ -54,12 +69,33 @@ export interface StatsLineProps { useSession: SnapshotSelectorHook s.nodes) - const stats = useMemo(() => deriveStats(nodes), [nodes]) - if (stats.steps === 0) return null + const metrics = useSession(s => s.metrics) + const counts = useMemo(() => deriveVisibleCounts(nodes), [nodes]) + if (counts.steps === 0 && ( + metrics === null + || ( + metrics.uncachedInputTokens === 0 + && metrics.outputTokens === 0 + && metrics.cacheReadTokens === 0 + && (metrics.contextTokens ?? 0) === 0 + ) + )) return null const parts: string[] = [] - if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`) - parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`) - parts.push(`${stats.turns} turns`) - parts.push(`${stats.steps} steps`) + if (metrics === null) { + parts.push('usage unknown') + parts.push('context unknown') + } else { + parts.push(`${formatMetricTokens(metrics.uncachedInputTokens)} uncached input`) + parts.push(`${formatMetricTokens(metrics.outputTokens)} output`) + parts.push(`${formatMetricTokens(metrics.cacheReadTokens)} cache read`) + const cacheHit = cacheHitPercent(metrics) + if (cacheHit !== null) parts.push(`cache hit ${cacheHit}%`) + const context = contextPercent(metrics) + parts.push(context === null + ? 'context unknown' + : `context ${context}% of ${formatMetricTokens(metrics.contextWindow as number)}`) + } + parts.push(`${counts.turns} turns`) + parts.push(`${counts.steps} steps`) return
{parts.join(' · ')}
}) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index bf1266981e..580070a1dc 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -129,15 +129,23 @@ describe('small branch tails', () => { }) it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => { - // cacheHitPct is null only when input+cacheRead are both zero (pure - // output accounting) — any input makes it a real 0%. const snap = { nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }], + metrics: { + logRevision: 2, + projectionRevision: 0, + uncachedInputTokens: 0, + outputTokens: 10, + cacheReadTokens: 0, + cacheWriteTokens: 5_000, + }, } const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( , ) - expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy() + expect(view.getByText( + '0 uncached input · 10 output · 0 cache read · context unknown · 1 turns · 1 steps', + )).toBeTruthy() }) }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index aa9451b413..e681020d0c 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -58,7 +58,7 @@ function snapshotWith( sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches, pending: [], queue: [], todos: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, metrics: null, } } diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 991aca36e0..fe9cba39b2 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -// StatsLine (rendered inside the chat view body): totals derivation + the RFC +// StatsLine (rendered inside the chat view body): durable metrics presentation + the RFC // hard acceptance — zero renders during streaming. Bash sample row: the // canonical sub-agent differential decided INSIDE the component off the // standard useSessions kit (no registry predicates — tool ring dissolved). @@ -12,7 +12,10 @@ import type { import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' +import { + cacheHitPercent, contextPercent, deriveVisibleCounts, formatMetricTokens, + StatsLine, type StatsLineProps, +} from '../src/client/chat/StatsLine.tsx' import { BashRow } from '../src/client/toolviews/bash-sample.tsx' afterEach(cleanup) @@ -28,7 +31,7 @@ function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, metrics: null, } } @@ -50,27 +53,49 @@ function makeSource(init?: Partial) { } } -describe('deriveStats', () => { - it('folds turns/steps/tokens and cache hit percentage', () => { - const stats = deriveStats([ +describe('stats derivation', () => { + it('counts visible turns and steps without reading node usage', () => { + const stats = deriveVisibleCounts([ assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }), assistant(2, 1, { inputTokens: 100, outputTokens: 50 }), assistant(3, 2), ]) expect(stats.turns).toBe(2) expect(stats.steps).toBe(3) - expect(stats.tokens).toBe(1200) - expect(stats.cacheHitPct).toBe(82) }) - it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => { + it('ignores non-assistant nodes', () => { const tool: ToolResultNode = { kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [], isError: false, callView: null, resultView: null, } - const stats = deriveStats([tool, assistant(1, 1)]) + const stats = deriveVisibleCounts([tool, assistant(1, 1)]) expect(stats.steps).toBe(1) - expect(stats.cacheHitPct).toBeNull() + }) + + it('keeps the cache formula disjoint from cache writes and rounds/clamps context like the TUI', () => { + const durable = { + logRevision: 20, + projectionRevision: 2, + uncachedInputTokens: 100, + outputTokens: 50, + cacheReadTokens: 900, + cacheWriteTokens: 50_000, + contextTokens: 34_500, + contextWindow: 100_000, + } + expect(cacheHitPercent(durable)).toBe(90) + expect(contextPercent(durable)).toBe(35) + expect(contextPercent({ ...durable, contextTokens: 200_000 })).toBe(100) + const { contextWindow: _contextWindow, ...withoutContextWindow } = durable + expect(contextPercent(withoutContextWindow)).toBeNull() + expect(cacheHitPercent({ ...durable, uncachedInputTokens: 0, cacheReadTokens: 0 })).toBeNull() + }) + + it('formats large values compactly in the existing en-US style', () => { + expect(formatMetricTokens(999)).toBe('999') + expect(formatMetricTokens(15_962)).toBe('16k') + expect(formatMetricTokens(2_172_544)).toBe('2.2m') }) }) @@ -79,19 +104,65 @@ describe('StatsLine', () => { return { useSession: bindSnapshotSelector(source) } } - it('renders the joined stats row and hides with zero steps', () => { + it('renders separate durable counters, cache hit, context occupancy, and visible counts', () => { const { source } = makeSource({ nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })], + metrics: { + logRevision: 30, + projectionRevision: 4, + uncachedInputTokens: 120_237, + outputTokens: 13_881, + cacheReadTokens: 2_172_544, + cacheWriteTokens: 99_999, + contextTokens: 89_600, + contextWindow: 256_000, + }, }) const view = render() - expect(view.getByText('cache hit 90% · 105 tokens · 1 turns · 1 steps')).toBeTruthy() + expect(view.getByText( + '120.2k uncached input · 13.9k output · 2.2m cache read · cache hit 95% · context 35% of 256k · 1 turns · 1 steps', + )).toBeTruthy() const empty = makeSource() const emptyView = render() expect(emptyView.container.textContent).toBe('') }) + it('renders honest unknowns when the host projection is missing', () => { + const { source } = makeSource({ nodes: [assistant(1, 1)] }) + const view = render() + expect(view.getByText('usage unknown · context unknown · 1 turns · 1 steps')).toBeTruthy() + }) + + it.each([ + { uncachedInputTokens: 1, outputTokens: 0, cacheReadTokens: 0, contextTokens: 0 }, + { uncachedInputTokens: 0, outputTokens: 1, cacheReadTokens: 0, contextTokens: 0 }, + { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 1, contextTokens: 0 }, + { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, contextTokens: 1 }, + ])('keeps a metrics-only row visible for each nonzero projection bucket', (nonzero) => { + const { source } = makeSource({ + metrics: { + logRevision: 1, + projectionRevision: 0, + cacheWriteTokens: 0, + ...nonzero, + }, + }) + const view = render() + expect(view.container.textContent).toContain('0 turns · 0 steps') + }) + it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => { - const { set, source } = makeSource({ nodes: [assistant(1, 1)] }) + const { set, source } = makeSource({ + nodes: [assistant(1, 1)], + metrics: { + logRevision: 4, + projectionRevision: 0, + uncachedInputTokens: 1, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }) let renders = 0 function Counting(p: StatsLineProps) { renders += 1 diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 87e188bbbd..05da39d195 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -41,7 +41,7 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot { return { sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, metrics: null, } } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 20389c9e23..08a4218332 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -31,7 +31,7 @@ function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, metrics: null, } } diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 8ee049f899..d34b784e01 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -20,7 +20,7 @@ function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, metrics: null, } } @@ -36,7 +36,7 @@ describe('render branch tails', () => { expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() }) - it('StatsLine skips usage-less nodes and defaults each absent counter to zero', () => { + it('StatsLine takes durable counters from metrics while keeping visible node counts', () => { const snap = { nodes: [ { kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] }, @@ -44,12 +44,22 @@ describe('render branch tails', () => { // outputTokens absent: the tokens sum's ?? 0 arm for output. { kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } }, ], + metrics: { + logRevision: 9, + projectionRevision: 1, + uncachedInputTokens: 9, + outputTokens: 6, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, } const source = { getSnapshot: () => snap, subscribe: () => () => {} } const view = render( } />, ) - expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy() + expect(view.getByText( + '9 uncached input · 6 output · 0 cache read · cache hit 0% · context unknown · 2 turns · 3 steps', + )).toBeTruthy() }) it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => { diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c50a35110a..0f10d61bf9 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -23,7 +23,7 @@ function snapshotOf(overrides: Partial = {}): Conversation sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, lastAgentError: null, + promptError: null, blank: false, lastAgentError: null, metrics: null, ...overrides, } } diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 284ef6c76a..65d4a69e32 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -26,7 +26,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], todos: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, - loadingOlder: false, promptError: null, blank: false, lastAgentError: null, + loadingOlder: false, promptError: null, blank: false, lastAgentError: null, metrics: null, }) const props: InputBarProps = { sessionId: SID, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 414f3c15b4..f2aad68296 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, lastAgentError: null, + promptError: null, blank: false, lastAgentError: null, metrics: null, }) const barProps: InputBarProps = { sessionId, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index fa0c871bdb..90a83b31e6 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -20,7 +20,7 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue, todos: [], running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null, metrics: null, } } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b777c85ac3..55e523abc3 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -50,7 +50,7 @@ function conversationSnapshot(overrides: Partial = {}): Co sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, - promptError: null, blank: false, lastAgentError: null, + promptError: null, blank: false, lastAgentError: null, metrics: null, ...overrides, } } diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 28ff470c0b..34593f89f9 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: d6db9a9541b0727b61dbe501f7234564ffef139e -README.zh.md: 4175c8fdb98aad2882718a2c95cd9e45825d787d +README.md: fa6fcb17ad3f2332e71e00387034fd7196759d8f +README.zh.md: cfdf0d772ac81a4e569a799569b5de6e509f3b6f diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d6db9a9541..fa6fcb17ad 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -18,7 +18,7 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create` `host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests. -`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state. +`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries session-level projections the page window cannot supply: the in-flight partial's chunk events; `todos`, the latest `todo/write` whole-list projection; and `metrics`, full-log usage deduplicated by `(turn, step)` plus current token-meter pressure and exact selected-route capacity when available. Older pages omit the session-level projections. Live `session/metrics` mux frames carry monotonic log/projection revisions, so clients reject stale frames and preserve the counters while prepending older pages. Cache reads and writes remain disjoint buckets; the cache-hit denominator is uncached input plus cache reads. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 4175c8fdb9..cfdf0d772a 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -18,7 +18,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。 -`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。 +`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)携带页窗口本身无法提供的会话级投影:进行中局部消息的分片事件;`todos`,即最后一次 `todo/write` 的整表投影;以及 `metrics`,即按 `(turn, step)` 去重的完整日志用量,并在可用时包含当前 token 计量压力和所选精确路由的容量。较早的页面省略会话级投影。实时 `session/metrics` mux 帧携带单调递增的日志修订号与投影修订号,因此客户端会拒绝陈旧帧,并在向前加载较早页面时保留计数器。缓存读取与缓存写入保持为彼此独立的计数项;缓存命中率的分母是未缓存输入加缓存读取。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 58922284be..13aa85c1fe 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -40,6 +40,7 @@ import type { } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' import { pickNativeDirectory } from './native-directory-picker.ts' +import { affectsSessionMetrics, SessionMetricsProjector } from './session-metrics.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 @@ -417,6 +418,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro for (const queue of muxQueues) queue.push(envelope) } + const pendingMetricSessions = new Set() + let metricFlushScheduled = false + let metricsDisposed = false + const metricsProjector = new SessionMetricsProjector( + ctx, + agent => targetFor(agent).current, + (agent) => { scheduleMetrics(agent.session) }, + ) + + /** Queue one full-log metrics publication after synchronous session listeners drain. */ + function scheduleMetrics(session: Session): void { + if (metricsDisposed || muxQueues.size === 0) return + pendingMetricSessions.add(session) + if (metricFlushScheduled) return + metricFlushScheduled = true + queueMicrotask(() => { + metricFlushScheduled = false + if (metricsDisposed) { + pendingMetricSessions.clear() + return + } + const sessions = [...pendingMetricSessions] + pendingMetricSessions.clear() + for (const current of sessions) { + broadcast({ + type: 'session/metrics', + sessionId: current.id, + metrics: metricsProjector.snapshot(current, ctx.agents.get(current.id)), + }) + } + }) + } + + ctx.effect(() => { + const disposers = [ + ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (affectsSessionMetrics(event)) scheduleMetrics(session) + }), + ctx.on('agent/created', (agent: Agent) => { scheduleMetrics(agent.session) }), + ctx.on('session/disposed', (session: Session) => { pendingMetricSessions.delete(session) }), + ] + return () => { + metricsDisposed = true + pendingMetricSessions.clear() + for (const dispose of disposers) dispose() + } + }, 'api-proxy: session metrics') + /** * Per-session inbox mirror serving the mux-open queue snapshot (the same * refresh-recovery baseline as pending questions). Keyed by the stable @@ -723,7 +772,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // log (the page window may not contain the last todo/write; a paged // client cannot reconstruct session-level state from it). const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined - return ok(request, { events: entries, hasMore: page.hasMore, ...todos === undefined ? {} : { todos } }) + const metrics = beforeSeq === undefined + ? metricsProjector.snapshot(found.agent.session, found.agent) + : undefined + return ok(request, { + events: entries, + hasMore: page.hasMore, + ...todos === undefined ? {} : { todos }, + ...metrics === undefined ? {} : { metrics }, + }) }, async models(request) { @@ -817,6 +874,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro : { reasoningEffort: resolved.reasoningEffort }, } targetFor(found.agent).current = selected + broadcast({ + type: 'session/metrics', + sessionId: found.agent.session.id, + metrics: metricsProjector.snapshot(found.agent.session, found.agent), + }) return ok(request, { selected: { ...selected } }) } catch (error: unknown) { return err(request, { @@ -1102,6 +1164,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro muxQueues.add(queue) for (const session of ctx.sessions.list()) { subscribeSession(queue, session) + queue.push(frame({ + type: 'session/metrics', + sessionId: session.id, + metrics: metricsProjector.snapshot(session, ctx.agents.get(session.id)), + })) } for (const pending of pendingQuestions.values()) { queue.push({ @@ -1154,6 +1221,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }), ctx.on('session/created', (session: Session) => { subscribeSession(queue, session) + queue.push(frame({ + type: 'session/metrics', + sessionId: session.id, + metrics: metricsProjector.snapshot(session, ctx.agents.get(session.id)), + })) }), ctx.on('session/disposed', (session: Session) => { openCalls.delete(session.id) diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 973db5a91e..0f317128dd 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -10,7 +10,9 @@ import type { HostFrame, MuxFrame } from './events.ts' import type { Wire } from './rpc.schema.ts' import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts' import { approvalRequestIdSchema } from './approvals.schema.ts' -import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts' +import { + contentBlockSchema, sessionEventSchema, sessionIdSchema, sessionMetricsSchema, toolEventViewSchema, +} from './sessions.schema.ts' import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' /** Question shape validated strictly against core dsh-user-interaction. */ @@ -27,6 +29,7 @@ export const askUserQuestionItemSchema = z.object({ export const muxFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }), z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }), + z.object({ type: z.literal('session/metrics'), sessionId: sessionIdSchema, metrics: sessionMetricsSchema }), z.object({ type: z.literal('session/title'), sessionId: sessionIdSchema, title: z.string().min(1), eventSeq: z.number().int().nonnegative(), updatedAt: z.number() }), z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }), z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 70139d0a00..73e2b42deb 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -13,6 +13,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' import type { RpcError, RpcId, RpcRequest } from './rpc.ts' +import type { SessionMetrics } from './sessions.ts' import type { WorkspaceView } from './workspace.ts' // Client-side consumers take the render-intent vocabulary from the contract; @@ -57,6 +58,7 @@ export interface EventsApi { export type MuxFrame = | { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView } | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number } + | { type: 'session/metrics'; sessionId: SessionId; metrics: SessionMetrics } | { type: 'session/title'; sessionId: SessionId; title: string; eventSeq: number; updatedAt: number } | { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string } | { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index ad5fbd3bf0..1b1268657c 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -27,7 +27,7 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionModels, SessionsApi, SessionSummary, + ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, SessionsApi, SessionSummary, } from './sessions.ts' export type { HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 02efe769af..0866766da5 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -11,7 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionSummary, + ModelReasoningEffort, ModelTarget, SessionMetrics, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' import type { WorkspaceId } from './workspace.ts' @@ -145,11 +145,24 @@ export const todoItemSchema = z.object({ status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]), }) +/** Host-owned durable usage and current-context projection. */ +export const sessionMetricsSchema = z.object({ + logRevision: z.number().int().nonnegative(), + projectionRevision: z.number().int().nonnegative(), + uncachedInputTokens: z.number().nonnegative(), + outputTokens: z.number().nonnegative(), + cacheReadTokens: z.number().nonnegative(), + cacheWriteTokens: z.number().nonnegative(), + contextTokens: z.number().nonnegative().optional(), + contextWindow: z.number().int().positive().optional(), +}) satisfies z.ZodType> + /** session.history response value. */ export const sessionHistoryValueSchema = z.object({ events: z.array(historyEntrySchema), hasMore: z.boolean(), todos: z.array(todoItemSchema).optional(), + metrics: sessionMetricsSchema.optional(), }) satisfies z.ZodType>> /** session.models request payload. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 88308c829b..9a0344867e 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -32,6 +32,31 @@ export interface HistoryEntry { view?: ToolEventView } +/** + * Host-owned token metrics for one durable session revision. Provider usage + * buckets are cumulative across the full log; current context fields describe + * the replayed request surface at this revision and are absent when the Host + * cannot measure pressure or resolve exact-route capacity. + */ +export interface SessionMetrics { + /** Number of durable events included in this projection. */ + logRevision: number + /** Monotone ordering within one Host process and mux subscription generation. */ + projectionRevision: number + /** Cumulative uncached provider input. */ + uncachedInputTokens: number + /** Cumulative provider output. */ + outputTokens: number + /** Cumulative provider cache reads. */ + cacheReadTokens: number + /** Cumulative provider cache writes; excluded from the Web cache-hit formula. */ + cacheWriteTokens: number + /** Current request pressure from `ctx.tokenMeter.measure(session).totalTokens`. */ + contextTokens?: number + /** Exact selected-route capacity from `ctx.llm.resolveModelInfo()`. */ + contextWindow?: number +} + /** Complete model target selected for one session. */ export interface ModelTarget { /** Registered provider route. */ @@ -153,9 +178,11 @@ export interface SessionsApi { * projection (latest `todo/write` over the FULL log, independent of the page window) — * so a paged client restores the plan without walking history; absent when the session * never wrote one. Older pages omit it (the projection is session-level, not per-page). + * The same tail-only rule carries `metrics`, whose cumulative usage and current context + * are Host projections over the full log rather than products of the returned page. */ history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): - Promise> + Promise> /** Reads a fresh advisory model directory for this session. Provider lookups run independently. */ models(request: RpcRequest<{ sessionId: SessionId }>): Promise> diff --git a/packages/host/apiproxy/src/session-metrics.ts b/packages/host/apiproxy/src/session-metrics.ts new file mode 100644 index 0000000000..2415a80182 --- /dev/null +++ b/packages/host/apiproxy/src/session-metrics.ts @@ -0,0 +1,194 @@ +/** + * Full-log usage and current-context projection for Web clients. + * + * @module @deepseek-ai/dsh-host-apiproxy/session-metrics + */ + +import type { Context } from 'cordis' +import type { Agent, AgentLlmTarget } from '@deepseek-ai/dsh-agent' +import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionMetrics } from './api/sessions.ts' + +interface UsageState { + logRevision: number + projectionRevision: number + uncachedInputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + byStep: Map +} + +interface CapacityState { + routeKey: string + generation: number + status: 'pending' | 'ready' + contextWindow?: number +} + +interface TokenMeterLike { + measure(session: Session): { totalTokens: number } +} + +interface LlmLike { + resolveModelInfo(provider: string, model: string): Promise<{ + context?: { contextWindow: number } + }> +} + +function usageFrom(event: SessionEvent): { turn: number; step: number; usage: TokenUsage } | undefined { + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { + return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage } + } + if (event.type === 'assistant/message' && event.data.usage !== undefined) { + return { turn: event.data.turn, step: event.data.step, usage: event.data.usage } + } + return undefined +} + +/** + * Whether an appended event can change cumulative usage or token-meter + * pressure. Text/reasoning stream deltas remain outside both projections. + * @param event - appended durable event. + * @returns true when the Host must publish a fresh metrics snapshot. + */ +export function affectsSessionMetrics(event: SessionEvent): boolean { + if (event.type === 'assistant/chunk') return event.data.chunk.type === 'usage' + if (event.type === 'request/header') return true + return 'surfaceOp' in event +} + +function recordUsage(state: UsageState, turn: number, step: number, usage: TokenUsage): void { + const key = `${turn}:${step}` + const previous = state.byStep.get(key) + if (previous !== undefined) { + state.uncachedInputTokens -= previous.inputTokens + state.outputTokens -= previous.outputTokens + state.cacheReadTokens -= previous.cacheReadTokens ?? 0 + state.cacheWriteTokens -= previous.cacheWriteTokens ?? 0 + } + state.byStep.set(key, usage) + state.uncachedInputTokens += usage.inputTokens + state.outputTokens += usage.outputTokens + state.cacheReadTokens += usage.cacheReadTokens ?? 0 + state.cacheWriteTokens += usage.cacheWriteTokens ?? 0 +} + +/** + * Projects durable cumulative usage and route-aware current context without + * awaiting model metadata on the session append path. + */ +export class SessionMetricsProjector { + private readonly usage = new WeakMap() + private readonly capacities = new WeakMap() + + /** + * @param ctx - Host context providing optional token-meter and LLM services. + * @param targetFor - selected route owner for one attached Web agent. + * @param onCapacityResolved - schedules a fresh live projection after exact-route metadata resolves. + */ + constructor( + private readonly ctx: Context, + private readonly targetFor: (agent: Agent) => Pick, + private readonly onCapacityResolved: (agent: Agent) => void, + ) {} + + /** + * Read a fresh detached projection through the session's durable tail. + * @param session - authoritative durable log owner. + * @param agent - attached route owner, when available. + * @returns cumulative usage and any currently available pressure/capacity. + */ + snapshot(session: Session, agent?: Agent): SessionMetrics { + const state = this.syncUsage(session) + const tokenMeter = this.ctx.get('tokenMeter') as TokenMeterLike | undefined + let contextTokens: number | undefined + if (tokenMeter !== undefined) { + try { + contextTokens = tokenMeter.measure(session).totalTokens + } catch { + // A malformed or temporarily unmeasurable replay has no honest pressure value. + } + } + const contextWindow = agent === undefined ? undefined : this.capacityFor(agent) + return { + logRevision: state.logRevision, + projectionRevision: state.projectionRevision++, + uncachedInputTokens: state.uncachedInputTokens, + outputTokens: state.outputTokens, + cacheReadTokens: state.cacheReadTokens, + cacheWriteTokens: state.cacheWriteTokens, + ...contextTokens === undefined ? {} : { contextTokens }, + ...contextWindow === undefined ? {} : { contextWindow }, + } + } + + private syncUsage(session: Session): UsageState { + let state = this.usage.get(session) + if (state === undefined) { + state = { + logRevision: 0, + projectionRevision: 0, + uncachedInputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + byStep: new Map(), + } + this.usage.set(session, state) + } + while (state.logRevision < session.events.length) { + const event = session.events[state.logRevision] + /* v8 ignore next -- Session events are append-only and dense; logRevision is bounded by length. */ + if (event === undefined) break + const usage = usageFrom(event) + if (usage !== undefined) recordUsage(state, usage.turn, usage.step, usage.usage) + state.logRevision++ + } + return state + } + + private capacityFor(agent: Agent): number | undefined { + const target = this.targetFor(agent) + const routeKey = `${target.provider}\u0000${target.model}` + let state = this.capacities.get(agent) + if (state === undefined || state.routeKey !== routeKey) { + state = { + routeKey, + generation: (state?.generation ?? 0) + 1, + status: 'pending', + } + this.capacities.set(agent, state) + this.resolveCapacity(agent, target, state) + } + return state.status === 'ready' ? state.contextWindow : undefined + } + + private resolveCapacity( + agent: Agent, + target: Pick, + pending: CapacityState, + ): void { + const llm = this.ctx.get('llm') as LlmLike | undefined + if (llm === undefined) { + pending.status = 'ready' + return + } + void Promise.resolve() + .then(() => llm.resolveModelInfo(target.provider, target.model)) + .then( + (resolved) => { + if (this.capacities.get(agent)?.generation !== pending.generation) return + const current = this.targetFor(agent) + if (`${current.provider}\u0000${current.model}` !== pending.routeKey) return + pending.status = 'ready' + if (resolved.context !== undefined) pending.contextWindow = resolved.context.contextWindow + this.onCapacityResolved(agent) + }, + () => { + if (this.capacities.get(agent)?.generation === pending.generation) pending.status = 'ready' + }, + ) + } +} diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index af8791431a..e0e7a035fa 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -19,6 +19,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import { createApiProxy } from '../src/api-proxy.ts' let nextRpc = 1 @@ -52,6 +53,7 @@ class CatalogAdapter extends LlmAdapter { provider, id: model, name: model, + context: { contextWindow: model === 'private-preview' ? 128_000 : 64_000 }, ...this.reasoning === undefined ? {} : { reasoning: this.reasoning }, }) } @@ -117,6 +119,16 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false return response.result.value } +async function nextMetrics( + iterator: AsyncIterator>, +): Promise['metrics']> { + for (;;) { + const next = await iterator.next() + if (next.done) throw new Error('mux ended before a metrics frame') + if (next.value.payload.type === 'session/metrics') return next.value.payload.metrics + } +} + describe('Web session model selection', () => { it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { const { ctx, sessionId } = await harness({ @@ -230,4 +242,26 @@ describe('Web session model selection', () => { .toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' }) await ctx.fiber.dispose() }) + + it('publishes unknown capacity immediately on selection, then the exact selected route capacity', async () => { + const { ctx, sessionId } = await harness() + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const controller = new AbortController() + const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() + + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + expect((await nextMetrics(iterator)).contextWindow).toBe(64_000) + + expectValue(await api.sessions.selectModel(request({ + sessionId, + provider: 'deepseek', + model: 'private-preview', + }))) + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) + + controller.abort() + await iterator.return?.() + await ctx.fiber.dispose() + }) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 9f1309b476..9a21341e26 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -10,7 +10,7 @@ import { sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema, sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema, sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema, - sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema, + sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema, sessionMetricsSchema, } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { @@ -138,8 +138,35 @@ describe('sessions domain schemas', () => { expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false, + metrics: { + logRevision: 12, + projectionRevision: 4, + uncachedInputTokens: 1_000, + outputTokens: 200, + cacheReadTokens: 4_000, + cacheWriteTokens: 500, + contextTokens: 8_000, + contextWindow: 128_000, + }, modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - }).hasMore).toBe(false) + }).metrics?.contextWindow).toBe(128_000) + expect(() => sessionMetricsSchema.parse({ + logRevision: 1, + projectionRevision: 0, + uncachedInputTokens: -1, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + })).toThrow() + expect(() => sessionMetricsSchema.parse({ + logRevision: 1, + projectionRevision: 0, + uncachedInputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + contextWindow: 0, + })).toThrow() expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionModelsValueSchema.parse({ current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, @@ -305,6 +332,18 @@ describe('events frame schemas', () => { { type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } }, { type: 'session/subscribed', sessionId: 's', lastSeq: -1 }, { type: 'session/title', sessionId: 's', title: 'Durable title', eventSeq: 2, updatedAt: 3 }, + { + type: 'session/metrics', + sessionId: 's', + metrics: { + logRevision: 3, + projectionRevision: 1, + uncachedInputTokens: 100, + outputTokens: 20, + cacheReadTokens: 300, + cacheWriteTokens: 40, + }, + }, { type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' }, { type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' }, { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, diff --git a/packages/host/apiproxy/tests/session-metrics.spec.ts b/packages/host/apiproxy/tests/session-metrics.spec.ts new file mode 100644 index 0000000000..beea6378ea --- /dev/null +++ b/packages/host/apiproxy/tests/session-metrics.spec.ts @@ -0,0 +1,277 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { Agent, AgentLlmTarget } from '@deepseek-ai/dsh-agent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { affectsSessionMetrics, SessionMetricsProjector } from '../src/session-metrics.ts' + +function assistant( + session: Session, + turn: number, + step: number, + usage: { + inputTokens: number + outputTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + }, +): void { + session.append('assistant/chunk', { + turn, + step, + chunk: { type: 'usage', usage }, + }) + session.append('assistant/message', { + turn, + step, + content: [{ type: 'text', text: `answer-${turn}-${step}` }], + provenance: { provider: 'test', model: 'alpha' }, + usage, + }, { surfaceOp: 'append' }) +} + +function agent(session: Session): Agent { + return { id: session.id, session } as Agent +} + +describe('SessionMetricsProjector', () => { + it('filters text/reasoning stream deltas while retaining usage, headers, and surface mutations', () => { + const session = new Session(SessionId('metrics-filter')) + const text = session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'x' }, + }) + const usage = session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }, + }) + const header = session.append('request/header', { + header: { config: { provider: 'test', model: 'alpha' } }, + reason: 'initial', + }) + const surface = session.append('user/message', { + content: [{ type: 'text', text: 'question' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + expect(affectsSessionMetrics(text)).toBe(false) + expect(affectsSessionMetrics(usage)).toBe(true) + expect(affectsSessionMetrics(header)).toBe(true) + expect(affectsSessionMetrics(surface)).toBe(true) + }) + + it('reconciles usage by turn:step, keeps cache writes disjoint, and survives a surface replacement', () => { + const ctx = new Context() + ctx.provide('tokenMeter', { + measure(session: Session) { + return { totalTokens: session.surface.nodes.length * 100 } + }, + }) + const session = new Session(SessionId('metrics-fold')) + const first = session.append('user/message', { + content: [{ type: 'text', text: 'large old surface' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + assistant(session, 1, 1, { + inputTokens: 11, + outputTokens: 3, + cacheReadTokens: 89, + cacheWriteTokens: 8, + }) + + const current: AgentLlmTarget = { provider: 'test', model: 'alpha' } + const projector = new SessionMetricsProjector(ctx, () => current, () => {}) + const attached = agent(session) + const before = projector.snapshot(session, attached) + expect(before).toMatchObject({ + uncachedInputTokens: 11, + outputTokens: 3, + cacheReadTokens: 89, + cacheWriteTokens: 8, + contextTokens: 200, + }) + + const assistantSeq = session.surface.nodes.at(-1) + if (assistantSeq === undefined) throw new Error('assistant surface missing') + session.append('user/message', { + content: [{ type: 'text', text: 'compact summary' }], + source: { kind: 'plugin', plugin: 'test' }, + }, { + surfaceOp: { op: 'replace', start: first.seq, end: assistantSeq }, + sourceEventSeqs: [first.seq, assistantSeq], + }) + // A replayed usage event for the same step replaces the settled value. + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { + type: 'usage', + usage: { + inputTokens: 12, + outputTokens: 4, + cacheReadTokens: 88, + cacheWriteTokens: 9, + }, + }, + }) + const compacted = projector.snapshot(session, attached) + expect(compacted).toMatchObject({ + uncachedInputTokens: 12, + outputTokens: 4, + cacheReadTokens: 88, + cacheWriteTokens: 9, + contextTokens: 100, + }) + assistant(session, 1, 2, { + inputTokens: 1_000, + outputTokens: 500, + cacheReadTokens: 2_000, + cacheWriteTokens: 3_000, + }) + + const after = projector.snapshot(session, attached) + expect(after).toMatchObject({ + logRevision: session.events.length, + projectionRevision: 2, + uncachedInputTokens: 1_012, + outputTokens: 504, + cacheReadTokens: 2_088, + cacheWriteTokens: 3_009, + contextTokens: 200, + }) + expect(after.uncachedInputTokens).not.toBe( + after.uncachedInputTokens + after.cacheReadTokens + after.cacheWriteTokens, + ) + }) + + it('publishes only the selected route capacity when asynchronous resolutions race', async () => { + const ctx = new Context() + const resolutions = new Map void>() + ctx.provide('tokenMeter', { measure: () => ({ totalTokens: 35_000 }) }) + ctx.provide('llm', { + resolveModelInfo(_provider: string, model: string) { + return new Promise<{ context: { contextWindow: number } }>((resolve) => { + resolutions.set(model, (contextWindow) => { resolve({ context: { contextWindow } }) }) + }) + }, + }) + const session = new Session(SessionId('capacity-race')) + const attached = agent(session) + let current: AgentLlmTarget = { provider: 'test', model: 'alpha' } + const resolved = vi.fn() + const projector = new SessionMetricsProjector(ctx, () => current, resolved) + + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(resolutions.has('alpha')).toBe(true) }) + current = { provider: 'test', model: 'beta' } + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(resolutions.has('beta')).toBe(true) }) + + resolutions.get('alpha')?.(64_000) + await Promise.resolve() + expect(resolved).not.toHaveBeenCalled() + resolutions.get('beta')?.(128_000) + await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) + expect(projector.snapshot(session, attached)).toMatchObject({ + contextTokens: 35_000, + contextWindow: 128_000, + }) + }) + + it('omits current context fields when measurement or model metadata is unavailable', async () => { + const ctx = new Context() + ctx.provide('tokenMeter', { measure: () => { throw new Error('unmeasurable') } }) + ctx.provide('llm', { resolveModelInfo: () => Promise.reject(new Error('metadata unavailable')) }) + const session = new Session(SessionId('missing-metrics')) + const attached = agent(session) + const projector = new SessionMetricsProjector( + ctx, + () => ({ provider: 'test', model: 'missing' }), + () => {}, + ) + const metrics = projector.snapshot(session, attached) + expect(metrics.contextTokens).toBeUndefined() + expect(metrics.contextWindow).toBeUndefined() + await vi.waitFor(() => { + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + }) + }) + + it('keeps optional usage buckets at zero and tolerates absent host services or detached agents', async () => { + const ctx = new Context() + const session = new Session(SessionId('optional-metrics')) + assistant(session, 1, 0, { inputTokens: 7, outputTokens: 2 }) + const attached = agent(session) + const projector = new SessionMetricsProjector( + ctx, + () => ({ provider: 'test', model: 'no-service' }), + () => {}, + ) + + expect(projector.snapshot(session)).toMatchObject({ + uncachedInputTokens: 7, + outputTokens: 2, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }) + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + await Promise.resolve() + }) + + it('publishes a resolved route with no advertised capacity as unknown', async () => { + const ctx = new Context() + ctx.provide('llm', { resolveModelInfo: () => Promise.resolve({}) }) + const session = new Session(SessionId('no-capacity')) + const attached = agent(session) + const resolved = vi.fn() + const projector = new SessionMetricsProjector( + ctx, + () => ({ provider: 'test', model: 'metadata-without-context' }), + resolved, + ) + + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + }) + + it('ignores stale resolution failures and route metadata after the target moves', async () => { + const ctx = new Context() + const resolutions = new Map() + ctx.provide('llm', { + resolveModelInfo(_provider: string, model: string) { + return new Promise<{ context: { contextWindow: number } }>((resolve, reject) => { + resolutions.set(model, { resolve, reject }) + }) + }, + }) + const session = new Session(SessionId('stale-capacity')) + const attached = agent(session) + let current: AgentLlmTarget = { provider: 'test', model: 'alpha' } + const resolved = vi.fn() + const targetFor = vi.fn(() => current) + const projector = new SessionMetricsProjector(ctx, targetFor, resolved) + + projector.snapshot(session, attached) + await vi.waitFor(() => { expect(resolutions.has('alpha')).toBe(true) }) + current = { provider: 'test', model: 'route-moved-before-snapshot' } + resolutions.get('alpha')?.resolve({ context: { contextWindow: 64_000 } }) + await vi.waitFor(() => { expect(targetFor).toHaveBeenCalledTimes(2) }) + expect(resolved).not.toHaveBeenCalled() + + projector.snapshot(session, attached) + await vi.waitFor(() => { expect(resolutions.has('route-moved-before-snapshot')).toBe(true) }) + current = { provider: 'test', model: 'beta' } + projector.snapshot(session, attached) + await vi.waitFor(() => { expect(resolutions.has('beta')).toBe(true) }) + resolutions.get('route-moved-before-snapshot')?.reject(new Error('stale failure')) + await Promise.resolve() + resolutions.get('beta')?.resolve({ context: { contextWindow: 128_000 } }) + await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) + expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) + }) +}) From 1fb1f00ec24ab7580286cc482d455dba7156e01e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 12:19:12 +0800 Subject: [PATCH 02/47] test(host): isolate compaction metric preservation (round 2) --- .../host/apiproxy/tests/session-metrics.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/host/apiproxy/tests/session-metrics.spec.ts b/packages/host/apiproxy/tests/session-metrics.spec.ts index beea6378ea..3ebfb1a0cf 100644 --- a/packages/host/apiproxy/tests/session-metrics.spec.ts +++ b/packages/host/apiproxy/tests/session-metrics.spec.ts @@ -100,6 +100,15 @@ describe('SessionMetricsProjector', () => { surfaceOp: { op: 'replace', start: first.seq, end: assistantSeq }, sourceEventSeqs: [first.seq, assistantSeq], }) + const compacted = projector.snapshot(session, attached) + expect(compacted).toMatchObject({ + uncachedInputTokens: 11, + outputTokens: 3, + cacheReadTokens: 89, + cacheWriteTokens: 8, + contextTokens: 100, + }) + // A replayed usage event for the same step replaces the settled value. session.append('assistant/chunk', { turn: 1, @@ -114,8 +123,8 @@ describe('SessionMetricsProjector', () => { }, }, }) - const compacted = projector.snapshot(session, attached) - expect(compacted).toMatchObject({ + const replayed = projector.snapshot(session, attached) + expect(replayed).toMatchObject({ uncachedInputTokens: 12, outputTokens: 4, cacheReadTokens: 88, @@ -132,7 +141,7 @@ describe('SessionMetricsProjector', () => { const after = projector.snapshot(session, attached) expect(after).toMatchObject({ logRevision: session.events.length, - projectionRevision: 2, + projectionRevision: 3, uncachedInputTokens: 1_012, outputTokens: 504, cacheReadTokens: 2_088, From 0dc0cc046b89295cf97c41c8a675bdb63edba22e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 13:57:26 +0800 Subject: [PATCH 03/47] fix(host): keep metrics route lookup passive (round 3) --- packages/host/apiproxy/src/api-proxy.ts | 16 +++- packages/host/apiproxy/src/session-metrics.ts | 39 ++++++--- .../apiproxy/tests/api-proxy-models.spec.ts | 80 ++++++++++++++++--- .../apiproxy/tests/session-metrics.spec.ts | 35 +++++++- 4 files changed, 146 insertions(+), 24 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 13aa85c1fe..84177de3cc 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -405,6 +405,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return target } + /** + * Read the best capacity route without taking ownership of foreign routing. + * Web agents expose their live selection; other agents expose only a route + * that already crossed the durable request-header boundary. + */ + function metricsRouteFor(agent: Agent): Pick | undefined { + const installed = targets.get(agent) + if (installed !== undefined) return installed.current + const logged = agent.session.requestHeader()?.config + return logged === undefined + ? undefined + : { provider: logged.provider, model: logged.model } + } + /** Pre-publication setup used by both fresh and resumed Web agents. */ function installTarget(agentCtx: Context): void { const agent = agentCtx.agent @@ -423,7 +437,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro let metricsDisposed = false const metricsProjector = new SessionMetricsProjector( ctx, - agent => targetFor(agent).current, + metricsRouteFor, (agent) => { scheduleMetrics(agent.session) }, ) diff --git a/packages/host/apiproxy/src/session-metrics.ts b/packages/host/apiproxy/src/session-metrics.ts index 2415a80182..161f92941e 100644 --- a/packages/host/apiproxy/src/session-metrics.ts +++ b/packages/host/apiproxy/src/session-metrics.ts @@ -21,12 +21,14 @@ interface UsageState { } interface CapacityState { - routeKey: string + routeKey: string | undefined generation: number status: 'pending' | 'ready' contextWindow?: number } +type CapacityTarget = Pick + interface TokenMeterLike { measure(session: Session): { totalTokens: number } } @@ -75,6 +77,10 @@ function recordUsage(state: UsageState, turn: number, step: number, usage: Token state.cacheWriteTokens += usage.cacheWriteTokens ?? 0 } +function routeKeyFor(target: CapacityTarget | undefined): string | undefined { + return target === undefined ? undefined : `${target.provider}\u0000${target.model}` +} + /** * Projects durable cumulative usage and route-aware current context without * awaiting model metadata on the session append path. @@ -85,12 +91,12 @@ export class SessionMetricsProjector { /** * @param ctx - Host context providing optional token-meter and LLM services. - * @param targetFor - selected route owner for one attached Web agent. + * @param targetFor - side-effect-free selected or logged route lookup for one attached agent. * @param onCapacityResolved - schedules a fresh live projection after exact-route metadata resolves. */ constructor( private readonly ctx: Context, - private readonly targetFor: (agent: Agent) => Pick, + private readonly targetFor: (agent: Agent) => CapacityTarget | undefined, private readonly onCapacityResolved: (agent: Agent) => void, ) {} @@ -151,23 +157,23 @@ export class SessionMetricsProjector { private capacityFor(agent: Agent): number | undefined { const target = this.targetFor(agent) - const routeKey = `${target.provider}\u0000${target.model}` + const routeKey = routeKeyFor(target) let state = this.capacities.get(agent) if (state === undefined || state.routeKey !== routeKey) { state = { routeKey, generation: (state?.generation ?? 0) + 1, - status: 'pending', + status: target === undefined ? 'ready' : 'pending', } this.capacities.set(agent, state) - this.resolveCapacity(agent, target, state) + if (target !== undefined) this.resolveCapacity(agent, target, state) } return state.status === 'ready' ? state.contextWindow : undefined } private resolveCapacity( agent: Agent, - target: Pick, + target: CapacityTarget, pending: CapacityState, ): void { const llm = this.ctx.get('llm') as LlmLike | undefined @@ -179,16 +185,27 @@ export class SessionMetricsProjector { .then(() => llm.resolveModelInfo(target.provider, target.model)) .then( (resolved) => { - if (this.capacities.get(agent)?.generation !== pending.generation) return - const current = this.targetFor(agent) - if (`${current.provider}\u0000${current.model}` !== pending.routeKey) return + if (this.capacityResolutionIsStale(agent, pending)) return pending.status = 'ready' if (resolved.context !== undefined) pending.contextWindow = resolved.context.contextWindow this.onCapacityResolved(agent) }, () => { - if (this.capacities.get(agent)?.generation === pending.generation) pending.status = 'ready' + if (!this.capacityResolutionIsStale(agent, pending)) pending.status = 'ready' }, ) } + + private capacityResolutionIsStale(agent: Agent, pending: CapacityState): boolean { + if (this.capacities.get(agent)?.generation !== pending.generation) return true + if (routeKeyFor(this.targetFor(agent)) === pending.routeKey) return false + // Unknown is the neutral generation; the next observed concrete route + // starts a fresh resolution even when it equals the route that disappeared. + this.capacities.set(agent, { + routeKey: undefined, + generation: pending.generation + 1, + status: 'ready', + }) + return true + } } diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index e0e7a035fa..1fc9f928e9 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, installAgentLlmTarget } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, @@ -72,15 +72,7 @@ const REASONING: LlmModelReasoningInfo = { defaultEffort: ReasoningEffortId('high'), } -async function harness(logged?: { - provider: string - model: string - reasoningEffort?: ReasoningEffortId -}): Promise<{ - ctx: Context - agent: Agent - sessionId: SessionId -}> { +async function hostContext(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) @@ -100,6 +92,19 @@ async function harness(logged?: { { provider: 'duplicate', id: 'same', name: 'Same' }, { provider: 'duplicate', id: 'same', name: 'Same Again' }, ])) + return ctx +} + +async function harness(logged?: { + provider: string + model: string + reasoningEffort?: ReasoningEffortId +}): Promise<{ + ctx: Context + agent: Agent + sessionId: SessionId +}> { + const ctx = await hostContext() const session = ctx.sessions.create() if (logged !== undefined) { session.append('request/header', { header: { config: logged }, reason: 'initial' }) @@ -246,6 +251,7 @@ describe('Web session model selection', () => { it('publishes unknown capacity immediately on selection, then the exact selected route capacity', async () => { const { ctx, sessionId } = await harness() const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + expectValue(await api.sessions.models(request({ sessionId }))) const controller = new AbortController() const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() @@ -264,4 +270,56 @@ describe('Web session model selection', () => { await iterator.return?.() await ctx.fiber.dispose() }) + + it('uses logged capacity without installing Web routing while scheduling foreign metrics', async () => { + const ctx = await hostContext() + const api = createApiProxy(ctx, { + provider: 'deepseek', + model: 'deepseek-chat', + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + const controller = new AbortController() + const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() + const initialMetrics = nextMetrics(iterator) + const session = ctx.sessions.create() + expect((await initialMetrics).contextWindow).toBeUndefined() + session.append('request/header', { + header: { config: { provider: 'deepseek', model: 'private-preview' } }, + reason: 'change', + }) + const foreign = { + id: session.id, + session, + status: 'running', + ctx, + } as Agent + const foreignTarget: AgentLlmTargetRef = { + current: { provider: 'foreign', model: 'foreign-model' }, + assembled: undefined, + } + const disposeForeignTarget = installAgentLlmTarget(foreign.ctx, foreignTarget) + const scheduledMetrics = nextMetrics(iterator) + ctx.agents.register(foreign) + + expect((await scheduledMetrics).contextWindow).toBeUndefined() + expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) + expect((await ctx.systemPrompt.assemble()).variables) + .toMatchObject({ provider: 'foreign', model: 'foreign-model' }) + const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } + const signal = new AbortController().signal + await expect(agentEvents(ctx, foreign).waterfall( + 'agent/request', 1, 0, signal, () => Promise.resolve(seed), + )).resolves.toMatchObject({ provider: 'foreign', model: 'foreign-model' }) + + disposeForeignTarget() + expect((await ctx.systemPrompt.assemble()).variables).not.toHaveProperty('provider') + await expect(agentEvents(ctx, foreign).waterfall( + 'agent/request', 1, 1, signal, () => Promise.resolve(seed), + )).resolves.toBe(seed) + + controller.abort() + await iterator.return?.() + await ctx.fiber.dispose() + }) }) diff --git a/packages/host/apiproxy/tests/session-metrics.spec.ts b/packages/host/apiproxy/tests/session-metrics.spec.ts index 3ebfb1a0cf..c92e5a76e4 100644 --- a/packages/host/apiproxy/tests/session-metrics.spec.ts +++ b/packages/host/apiproxy/tests/session-metrics.spec.ts @@ -187,6 +187,37 @@ describe('SessionMetricsProjector', () => { }) }) + it('starts a fresh capacity generation when an unavailable route returns', async () => { + const ctx = new Context() + const resolutions: ((contextWindow: number) => void)[] = [] + ctx.provide('llm', { + resolveModelInfo() { + return new Promise<{ context: { contextWindow: number } }>((resolve) => { + resolutions.push((contextWindow) => { resolve({ context: { contextWindow } }) }) + }) + }, + }) + const session = new Session(SessionId('capacity-route-return')) + const attached = agent(session) + let current: AgentLlmTarget | undefined = { provider: 'test', model: 'alpha' } + const resolved = vi.fn() + const targetFor = vi.fn(() => current) + const projector = new SessionMetricsProjector(ctx, targetFor, resolved) + + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(resolutions).toHaveLength(1) }) + current = undefined + resolutions[0]?.(64_000) + await vi.waitFor(() => { expect(targetFor).toHaveBeenCalledTimes(2) }) + expect(resolved).not.toHaveBeenCalled() + current = { provider: 'test', model: 'alpha' } + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(resolutions).toHaveLength(2) }) + resolutions[1]?.(128_000) + await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) + expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) + }) + it('omits current context fields when measurement or model metadata is unavailable', async () => { const ctx = new Context() ctx.provide('tokenMeter', { measure: () => { throw new Error('unmeasurable') } }) @@ -211,9 +242,10 @@ describe('SessionMetricsProjector', () => { const session = new Session(SessionId('optional-metrics')) assistant(session, 1, 0, { inputTokens: 7, outputTokens: 2 }) const attached = agent(session) + const selected: { current?: AgentLlmTarget } = {} const projector = new SessionMetricsProjector( ctx, - () => ({ provider: 'test', model: 'no-service' }), + () => selected.current, () => {}, ) @@ -224,6 +256,7 @@ describe('SessionMetricsProjector', () => { cacheWriteTokens: 0, }) expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + selected.current = { provider: 'test', model: 'no-service' } expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() await Promise.resolve() }) From 765b360e264bbc7f781b3050caa759f46cca0f7b Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 14:29:11 +0800 Subject: [PATCH 04/47] fix(host): fence stale metric completions (round 4) --- packages/host/apiproxy/src/api-proxy.ts | 7 +- .../apiproxy/tests/api-proxy-models.spec.ts | 138 +++++++++++++++++- 2 files changed, 141 insertions(+), 4 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 84177de3cc..e55fc29f58 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -438,7 +438,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const metricsProjector = new SessionMetricsProjector( ctx, metricsRouteFor, - (agent) => { scheduleMetrics(agent.session) }, + (agent) => { + if (metricsDisposed) return + if (ctx.agents.get(agent.id) !== agent) return + if (ctx.sessions.get(agent.id) !== agent.session) return + scheduleMetrics(agent.session) + }, ) /** Queue one full-log metrics publication after synchronous session listeners drain. */ diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 1fc9f928e9..366c3cd597 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -4,7 +4,7 @@ * models, and the prompt-assembly boundary for a running selection change. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents, installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' @@ -13,8 +13,8 @@ import type { GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk, } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import type { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -63,6 +63,33 @@ class CatalogAdapter extends LlmAdapter { } } +class DeferredCatalogAdapter extends CatalogAdapter { + readonly pending: PromiseWithResolvers[] = [] + + constructor() { + super('Deferred', [ + { provider: 'deferred', id: 'lifecycle-model', name: 'Lifecycle model' }, + ]) + } + + override resolveModel(_provider: string, _model: string): Promise { + const result = Promise.withResolvers() + this.pending.push(result) + return result.promise + } + + resolve(index: number, contextWindow: number): void { + const pending = this.pending[index] + if (pending === undefined) throw new Error(`no pending resolution at index ${String(index)}`) + pending.resolve({ + provider: 'deferred', + id: 'lifecycle-model', + name: 'Lifecycle model', + context: { contextWindow }, + }) + } +} + const REASONING: LlmModelReasoningInfo = { efforts: [ { id: ReasoningEffortId('off'), name: 'Off' }, @@ -134,6 +161,46 @@ async function nextMetrics( } } +function attachLifecycleSession( + ctx: Context, + sessionId: SessionId, + withMarker = false, +): { session: Session; detach: () => void } { + const session = ctx.sessions.prepare(sessionId) + session.append('request/header', { + header: { config: { provider: 'deferred', model: 'lifecycle-model' } }, + reason: 'initial', + }) + if (withMarker) { + session.append('user/message', { + content: [{ type: 'text', text: 'replacement marker' }], + source: { kind: 'plugin', plugin: 'test' }, + }, { surfaceOp: 'append' }) + } + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + return { session, detach } +} + +function attachLifecycleAgent( + ctx: Context, + session: Session, +): () => void { + const agent = { + id: session.id, + session, + status: 'running', + ctx, + } as Agent + const detach = ctx.agents.enter(agent, undefined) + ctx.agents.announce(agent) + return detach +} + +function settleCapacityCompletion(): Promise { + return new Promise((resolve) => { setImmediate(resolve) }) +} + describe('Web session model selection', () => { it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { const { ctx, sessionId } = await harness({ @@ -322,4 +389,69 @@ describe('Web session model selection', () => { await iterator.return?.() await ctx.fiber.dispose() }) + + it('drops capacity completion from a replaced agent that retains the exact session', async () => { + const ctx = await hostContext() + const deferred = new DeferredCatalogAdapter() + ctx.llm.registerAdapter(['deferred'], deferred) + const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-agent-lifecycle')) + const retire = attachLifecycleAgent(ctx, lifecycle.session) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const controller = new AbortController() + const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() + + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) + retire() + const detachLive = attachLifecycleAgent(ctx, lifecycle.session) + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) }) + + deferred.resolve(0, 64_000) + await settleCapacityCompletion() + deferred.resolve(1, 128_000) + await settleCapacityCompletion() + expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) + + controller.abort() + await iterator.return?.() + detachLive() + lifecycle.detach() + await ctx.fiber.dispose() + }) + + it('drops capacity completion from a replaced session while its old agent remains live', async () => { + const ctx = await hostContext() + const deferred = new DeferredCatalogAdapter() + ctx.llm.registerAdapter(['deferred'], deferred) + const sessionId = SessionId('capacity-session-lifecycle') + const retiredSession = attachLifecycleSession(ctx, sessionId) + const retireAgent = attachLifecycleAgent(ctx, retiredSession.session) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const controller = new AbortController() + const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() + + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) + retiredSession.detach() + const liveSession = attachLifecycleSession(ctx, sessionId, true) + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + deferred.resolve(0, 64_000) + await settleCapacityCompletion() + retireAgent() + const detachLiveAgent = attachLifecycleAgent(ctx, liveSession.session) + const scheduled = await nextMetrics(iterator) + expect(scheduled.logRevision).toBe(2) + expect(scheduled.contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) }) + deferred.resolve(1, 128_000) + await settleCapacityCompletion() + expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) + + controller.abort() + await iterator.return?.() + detachLiveAgent() + liveSession.detach() + await ctx.fiber.dispose() + }) }) From 6f002007b08896a3641b3285d3bbeb42156a54b8 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 14:51:11 +0800 Subject: [PATCH 05/47] fix(host): pair metric agents by lifecycle (round 5) --- packages/host/apiproxy/src/api-proxy.ts | 12 +++- .../apiproxy/tests/api-proxy-models.spec.ts | 55 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e55fc29f58..7eec1d8bb6 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -419,6 +419,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro : { provider: logged.provider, model: logged.model } } + /** Pair a registry agent only with the exact Session lifecycle it owns. */ + function metricsAgentFor(session: Session): Agent | undefined { + const agent = ctx.agents.get(session.id) + return agent?.session === session ? agent : undefined + } + /** Pre-publication setup used by both fresh and resumed Web agents. */ function installTarget(agentCtx: Context): void { const agent = agentCtx.agent @@ -464,7 +470,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro broadcast({ type: 'session/metrics', sessionId: current.id, - metrics: metricsProjector.snapshot(current, ctx.agents.get(current.id)), + metrics: metricsProjector.snapshot(current, metricsAgentFor(current)), }) } }) @@ -1186,7 +1192,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'session/metrics', sessionId: session.id, - metrics: metricsProjector.snapshot(session, ctx.agents.get(session.id)), + metrics: metricsProjector.snapshot(session, metricsAgentFor(session)), })) } for (const pending of pendingQuestions.values()) { @@ -1243,7 +1249,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'session/metrics', sessionId: session.id, - metrics: metricsProjector.snapshot(session, ctx.agents.get(session.id)), + metrics: metricsProjector.snapshot(session, metricsAgentFor(session)), })) }), ctx.on('session/disposed', (session: Session) => { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 366c3cd597..722c2e7412 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -454,4 +454,59 @@ describe('Web session model selection', () => { liveSession.detach() await ctx.fiber.dispose() }) + + it('does not project retired agent capacity into replacement session snapshots', async () => { + const ctx = await hostContext() + const deferred = new DeferredCatalogAdapter() + ctx.llm.registerAdapter(['deferred'], deferred) + const sessionId = SessionId('capacity-snapshot-lifecycle') + const retiredSession = attachLifecycleSession(ctx, sessionId) + const retireAgent = attachLifecycleAgent(ctx, retiredSession.session) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const primaryController = new AbortController() + const primary = api.events.mux(request({}), primaryController.signal)[Symbol.asyncIterator]() + + expect((await nextMetrics(primary)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) + deferred.resolve(0, 64_000) + await settleCapacityCompletion() + expect((await nextMetrics(primary)).contextWindow).toBe(64_000) + + retiredSession.detach() + const replacement = attachLifecycleSession(ctx, sessionId) + const createdBaseline = await nextMetrics(primary) + replacement.session.append('user/message', { + content: [{ type: 'text', text: 'replacement marker' }], + source: { kind: 'plugin', plugin: 'test' }, + }, { surfaceOp: 'append' }) + const scheduledFlush = await nextMetrics(primary) + const reconnectController = new AbortController() + const reconnect = api.events.mux(request({}), reconnectController.signal)[Symbol.asyncIterator]() + const reconnectBaseline = await nextMetrics(reconnect) + expect(createdBaseline.logRevision).toBe(1) + for (const metrics of [scheduledFlush, reconnectBaseline]) { + expect(metrics.logRevision).toBe(2) + } + expect({ + created: createdBaseline.contextWindow, + scheduled: scheduledFlush.contextWindow, + reconnect: reconnectBaseline.contextWindow, + }).toEqual({ created: undefined, scheduled: undefined, reconnect: undefined }) + + retireAgent() + const detachReplacementAgent = attachLifecycleAgent(ctx, replacement.session) + expect((await nextMetrics(primary)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) }) + deferred.resolve(1, 128_000) + await settleCapacityCompletion() + expect((await nextMetrics(primary)).contextWindow).toBe(128_000) + + primaryController.abort() + reconnectController.abort() + await primary.return?.() + await reconnect.return?.() + detachReplacementAgent() + replacement.detach() + await ctx.fiber.dispose() + }) }) From 03ca568246bff66a92f25f7d6313769338bbbfe5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 15:41:38 +0800 Subject: [PATCH 06/47] fix(host): refresh metric capacity metadata (round 6) --- packages/host/apiproxy/src/api-proxy.ts | 11 +++ packages/host/apiproxy/src/session-metrics.ts | 21 ++++-- .../apiproxy/tests/api-proxy-models.spec.ts | 69 +++++++++++++++++- .../apiproxy/tests/session-metrics.spec.ts | 71 +++++++++++++++++++ 4 files changed, 166 insertions(+), 6 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 7eec1d8bb6..b1c079a6d9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import { join } from 'node:path' +import { FiberState } from 'cordis' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { @@ -483,6 +484,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }), ctx.on('agent/created', (agent: Agent) => { scheduleMetrics(agent.session) }), ctx.on('session/disposed', (session: Session) => { pendingMetricSessions.delete(session) }), + ctx.on('internal/status', (fiber) => { + if (metricsDisposed) return + if (fiber.state !== FiberState.ACTIVE + && fiber.state !== FiberState.FAILED + && fiber.state !== FiberState.DISPOSED) return + const sessions = ctx.get('sessions') + if (sessions === undefined) return + metricsProjector.invalidateCapacities() + for (const session of sessions.list()) scheduleMetrics(session) + }, { global: true }), ] return () => { metricsDisposed = true diff --git a/packages/host/apiproxy/src/session-metrics.ts b/packages/host/apiproxy/src/session-metrics.ts index 161f92941e..1ad1f488af 100644 --- a/packages/host/apiproxy/src/session-metrics.ts +++ b/packages/host/apiproxy/src/session-metrics.ts @@ -23,7 +23,8 @@ interface UsageState { interface CapacityState { routeKey: string | undefined generation: number - status: 'pending' | 'ready' + epoch: number + status: 'pending' | 'ready' | 'retryable' contextWindow?: number } @@ -88,6 +89,7 @@ function routeKeyFor(target: CapacityTarget | undefined): string | undefined { export class SessionMetricsProjector { private readonly usage = new WeakMap() private readonly capacities = new WeakMap() + private capacityEpoch = 0 /** * @param ctx - Host context providing optional token-meter and LLM services. @@ -100,6 +102,11 @@ export class SessionMetricsProjector { private readonly onCapacityResolved: (agent: Agent) => void, ) {} + /** Retire adapter-owned metadata and fence every resolution already in flight. */ + invalidateCapacities(): void { + this.capacityEpoch++ + } + /** * Read a fresh detached projection through the session's durable tail. * @param session - authoritative durable log owner. @@ -159,10 +166,14 @@ export class SessionMetricsProjector { const target = this.targetFor(agent) const routeKey = routeKeyFor(target) let state = this.capacities.get(agent) - if (state === undefined || state.routeKey !== routeKey) { + if (state === undefined + || state.routeKey !== routeKey + || state.epoch !== this.capacityEpoch + || state.status === 'retryable') { state = { routeKey, generation: (state?.generation ?? 0) + 1, + epoch: this.capacityEpoch, status: target === undefined ? 'ready' : 'pending', } this.capacities.set(agent, state) @@ -178,7 +189,7 @@ export class SessionMetricsProjector { ): void { const llm = this.ctx.get('llm') as LlmLike | undefined if (llm === undefined) { - pending.status = 'ready' + pending.status = 'retryable' return } void Promise.resolve() @@ -191,12 +202,13 @@ export class SessionMetricsProjector { this.onCapacityResolved(agent) }, () => { - if (!this.capacityResolutionIsStale(agent, pending)) pending.status = 'ready' + if (!this.capacityResolutionIsStale(agent, pending)) pending.status = 'retryable' }, ) } private capacityResolutionIsStale(agent: Agent, pending: CapacityState): boolean { + if (pending.epoch !== this.capacityEpoch) return true if (this.capacities.get(agent)?.generation !== pending.generation) return true if (routeKeyFor(this.targetFor(agent)) === pending.routeKey) return false // Unknown is the neutral generation; the next observed concrete route @@ -204,6 +216,7 @@ export class SessionMetricsProjector { this.capacities.set(agent, { routeKey: undefined, generation: pending.generation + 1, + epoch: this.capacityEpoch, status: 'ready', }) return true diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 722c2e7412..5a1d9f02cf 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' +import type { Fiber } from 'cordis' import AgentRegistry, { agentEvents, installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' @@ -99,9 +100,10 @@ const REASONING: LlmModelReasoningInfo = { defaultEffort: ReasoningEffortId('high'), } -async function hostContext(): Promise { +async function hostContext(onSessions?: (fiber: Fiber) => void): Promise { const ctx = new Context() - await ctx.plugin(SessionStore) + const sessionsFiber = await ctx.plugin(SessionStore) + onSessions?.(sessionsFiber) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(LlmService) await ctx.plugin(UserInteractionService) @@ -201,6 +203,15 @@ function settleCapacityCompletion(): Promise { return new Promise((resolve) => { setImmediate(resolve) }) } +function installDeferredAdapter( + ctx: Context, + adapter: DeferredCatalogAdapter, +): Fiber & PromiseLike { + return ctx.plugin(Object.assign((inner: Context) => { + inner.llm.registerAdapter(['deferred'], adapter) + }, { inject: ['llm'] })) +} + describe('Web session model selection', () => { it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { const { ctx, sessionId } = await harness({ @@ -509,4 +520,58 @@ describe('Web session model selection', () => { replacement.detach() await ctx.fiber.dispose() }) + + it('refreshes same-route capacity after adapter owner replacement', async () => { + const ctx = await hostContext() + const retiredAdapter = new DeferredCatalogAdapter() + const retiredFiber = await installDeferredAdapter(ctx, retiredAdapter) + const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-lifecycle')) + const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const controller = new AbortController() + const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() + + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) }) + retiredAdapter.resolve(0, 64_000) + await settleCapacityCompletion() + expect((await nextMetrics(iterator)).contextWindow).toBe(64_000) + + await retiredFiber.dispose() + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + const replacementAdapter = new DeferredCatalogAdapter() + const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter) + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) }) + replacementAdapter.resolve(0, 128_000) + await settleCapacityCompletion() + expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) + expect(lifecycle.session.requestHeader()?.config).toMatchObject({ + provider: 'deferred', + model: 'lifecycle-model', + }) + + controller.abort() + await iterator.return?.() + detachAgent() + lifecycle.detach() + await replacementFiber.dispose() + await ctx.fiber.dispose() + }) + + it('does not read the sessions service after its disposal status', async () => { + let sessionsFiber: Fiber | undefined + const ctx = await hostContext((fiber) => { sessionsFiber = fiber }) + if (sessionsFiber === undefined) throw new Error('sessions fiber missing') + createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const sessions = ctx.get('sessions') + if (sessions === undefined) throw new Error('sessions service missing') + const list = vi.spyOn(sessions, 'list').mockImplementation(() => { + throw new Error('disposed sessions service read') + }) + + await expect(sessionsFiber.dispose()).resolves.toBeUndefined() + expect(list).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) }) diff --git a/packages/host/apiproxy/tests/session-metrics.spec.ts b/packages/host/apiproxy/tests/session-metrics.spec.ts index c92e5a76e4..3b3653aad8 100644 --- a/packages/host/apiproxy/tests/session-metrics.spec.ts +++ b/packages/host/apiproxy/tests/session-metrics.spec.ts @@ -33,6 +33,10 @@ function agent(session: Session): Agent { return { id: session.id, session } as Agent } +function settleAsyncWork(): Promise { + return new Promise((resolve) => { setImmediate(resolve) }) +} + describe('SessionMetricsProjector', () => { it('filters text/reasoning stream deltas while retaining usage, headers, and surface mutations', () => { const session = new Session(SessionId('metrics-filter')) @@ -187,6 +191,73 @@ describe('SessionMetricsProjector', () => { }) }) + it('retries a failed same-route capacity lookup only on the next snapshot', async () => { + const ctx = new Context() + const attempts: PromiseWithResolvers<{ context: { contextWindow: number } }>[] = [] + ctx.provide('llm', { + resolveModelInfo() { + const attempt = Promise.withResolvers<{ context: { contextWindow: number } }>() + attempts.push(attempt) + return attempt.promise + }, + }) + const session = new Session(SessionId('capacity-retry')) + const attached = agent(session) + const resolved = vi.fn() + const projector = new SessionMetricsProjector( + ctx, + () => ({ provider: 'test', model: 'alpha' }), + resolved, + ) + + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(attempts).toHaveLength(1) }) + attempts[0]?.reject(new Error('metadata temporarily unavailable')) + await settleAsyncWork() + expect(attempts).toHaveLength(1) + expect(resolved).not.toHaveBeenCalled() + + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + await settleAsyncWork() + expect(attempts).toHaveLength(2) + attempts[1]?.resolve({ context: { contextWindow: 128_000 } }) + await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) + expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) + }) + + it('invalidates same-route capacity and fences the prior epoch in flight', async () => { + const ctx = new Context() + const attempts: PromiseWithResolvers<{ context: { contextWindow: number } }>[] = [] + ctx.provide('llm', { + resolveModelInfo() { + const attempt = Promise.withResolvers<{ context: { contextWindow: number } }>() + attempts.push(attempt) + return attempt.promise + }, + }) + const session = new Session(SessionId('capacity-invalidation')) + const attached = agent(session) + const resolved = vi.fn() + const projector = new SessionMetricsProjector( + ctx, + () => ({ provider: 'test', model: 'alpha' }), + resolved, + ) + + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(attempts).toHaveLength(1) }) + projector.invalidateCapacities() + attempts[0]?.resolve({ context: { contextWindow: 64_000 } }) + await settleAsyncWork() + expect(resolved).not.toHaveBeenCalled() + + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(attempts).toHaveLength(2) }) + attempts[1]?.resolve({ context: { contextWindow: 128_000 } }) + await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) + expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) + }) + it('starts a fresh capacity generation when an unavailable route returns', async () => { const ctx = new Context() const resolutions: ((contextWindow: number) => void)[] = [] From 8de47601415517df190187c420ae2b528d470e77 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 15:56:28 +0800 Subject: [PATCH 07/47] fix(host): fence metric teardown reads (round 6 review) --- packages/host/apiproxy/src/api-proxy.ts | 8 +- .../apiproxy/tests/api-proxy-models.spec.ts | 114 +++++++++++++++++- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index b1c079a6d9..0b04395d90 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -447,8 +447,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro metricsRouteFor, (agent) => { if (metricsDisposed) return - if (ctx.agents.get(agent.id) !== agent) return - if (ctx.sessions.get(agent.id) !== agent.session) return + const agents = ctx.get('agents') + if (agents?.get(agent.id) !== agent) return + const sessions = ctx.get('sessions') + if (sessions?.get(agent.id) !== agent.session) return scheduleMetrics(agent.session) }, ) @@ -489,9 +491,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (fiber.state !== FiberState.ACTIVE && fiber.state !== FiberState.FAILED && fiber.state !== FiberState.DISPOSED) return + metricsProjector.invalidateCapacities() const sessions = ctx.get('sessions') if (sessions === undefined) return - metricsProjector.invalidateCapacities() for (const session of sessions.list()) scheduleMetrics(session) }, { global: true }), ] diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 5a1d9f02cf..9955a91b79 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context, FiberState } from 'cordis' import type { Fiber } from 'cordis' import AgentRegistry, { agentEvents, installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { Agent, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' @@ -100,14 +100,18 @@ const REASONING: LlmModelReasoningInfo = { defaultEffort: ReasoningEffortId('high'), } -async function hostContext(onSessions?: (fiber: Fiber) => void): Promise { +async function hostContext( + onSessions?: (fiber: Fiber) => void, + onAgents?: (fiber: Fiber) => void, +): Promise { const ctx = new Context() const sessionsFiber = await ctx.plugin(SessionStore) onSessions?.(sessionsFiber) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(LlmService) await ctx.plugin(UserInteractionService) - await ctx.plugin(AgentRegistry) + const agentsFiber = await ctx.plugin(AgentRegistry) + onAgents?.(agentsFiber) ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [ { provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' }, { provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' }, @@ -574,4 +578,108 @@ describe('Web session model selection', () => { expect(list).not.toHaveBeenCalled() await ctx.fiber.dispose() }) + + it('invalidates pending capacity before SessionStore teardown can reach its callback', async () => { + let sessionsFiber: Fiber | undefined + const ctx = await hostContext((fiber) => { sessionsFiber = fiber }) + if (sessionsFiber === undefined) throw new Error('sessions fiber missing') + const deferred = new DeferredCatalogAdapter() + ctx.llm.registerAdapter(['deferred'], deferred) + const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-session-store-teardown')) + const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const controller = new AbortController() + const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() + + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) + const agents = ctx.get('agents') + if (agents === undefined) throw new Error('agent registry missing') + expect(agents.get(lifecycle.session.id)).toBeDefined() + const getAgent = vi.spyOn(agents, 'get') + + await sessionsFiber.dispose() + getAgent.mockClear() + deferred.resolve(0, 64_000) + await settleCapacityCompletion() + expect(getAgent).not.toHaveBeenCalled() + + const pendingFrame = iterator.next() + const outcome = await Promise.race([ + pendingFrame.then(() => 'frame' as const), + new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }), + ]) + expect(outcome).toBe('idle') + + controller.abort() + await expect(pendingFrame).resolves.toMatchObject({ done: true }) + await iterator.return?.() + detachAgent() + lifecycle.detach() + await ctx.fiber.dispose() + }) + + it.each(['agents', 'sessions'] as const)( + 'drops capacity completion while %s is unavailable during unload', + async (serviceName) => { + let sessionsFiber: Fiber | undefined + let agentsFiber: Fiber | undefined + const ctx = await hostContext( + (fiber) => { sessionsFiber = fiber }, + (fiber) => { agentsFiber = fiber }, + ) + const heldFiber = serviceName === 'sessions' ? sessionsFiber : agentsFiber + if (heldFiber === undefined) throw new Error(`${serviceName} fiber missing`) + const unloadStarted = Promise.withResolvers() + const releaseUnload = Promise.withResolvers() + heldFiber.ctx.effect(() => () => { + unloadStarted.resolve(undefined) + return releaseUnload.promise + }, `test: hold ${serviceName} unload`) + const deferred = new DeferredCatalogAdapter() + ctx.llm.registerAdapter(['deferred'], deferred) + const lifecycle = attachLifecycleSession(ctx, SessionId(`capacity-${serviceName}-unloading`)) + const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const controller = new AbortController() + const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() + + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) + const agents = ctx.get('agents') + if (agents === undefined) throw new Error('agent registry missing') + const sessions = ctx.get('sessions') + if (sessions === undefined) throw new Error('sessions service missing') + const getAgent = vi.spyOn(agents, 'get') + const getSession = vi.spyOn(sessions, 'get') + const disposing = heldFiber.dispose() + await unloadStarted.promise + await vi.waitFor(() => { expect(ctx.get(serviceName)).toBeUndefined() }) + expect(heldFiber.state).toBe(FiberState.UNLOADING) + + getAgent.mockClear() + getSession.mockClear() + deferred.resolve(0, 64_000) + await settleCapacityCompletion() + const agentReads = getAgent.mock.calls.length + const sessionReads = getSession.mock.calls.length + const pendingFrame = iterator.next() + const outcome = await Promise.race([ + pendingFrame.then(() => 'frame' as const), + new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }), + ]) + + controller.abort() + await expect(pendingFrame).resolves.toMatchObject({ done: true }) + await iterator.return?.() + releaseUnload.resolve(undefined) + await disposing + detachAgent() + lifecycle.detach() + await ctx.fiber.dispose() + expect(agentReads).toBe(serviceName === 'sessions' ? 1 : 0) + expect(sessionReads).toBe(0) + expect(outcome).toBe('idle') + }, + ) }) From 5340aedb8bdce3c36f9101dd48948c1976a6c70f Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 16:05:43 +0800 Subject: [PATCH 08/47] fix(host): guard terminal metric snapshots (round 6 review) --- packages/host/apiproxy/src/api-proxy.ts | 2 +- .../apiproxy/tests/api-proxy-models.spec.ts | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 0b04395d90..149143b900 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -422,7 +422,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** Pair a registry agent only with the exact Session lifecycle it owns. */ function metricsAgentFor(session: Session): Agent | undefined { - const agent = ctx.agents.get(session.id) + const agent = ctx.get('agents')?.get(session.id) return agent?.session === session ? agent : undefined } diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 9955a91b79..758669f5db 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -619,6 +619,49 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('publishes unknown metrics after AgentRegistry terminal disposal with mux active', async () => { + let agentsFiber: Fiber | undefined + const ctx = await hostContext(undefined, (fiber) => { agentsFiber = fiber }) + if (agentsFiber === undefined) throw new Error('agent registry fiber missing') + const deferred = new DeferredCatalogAdapter() + ctx.llm.registerAdapter(['deferred'], deferred) + const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-agent-registry-disposed')) + const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) + const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const controller = new AbortController() + const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() + + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) + deferred.resolve(0, 64_000) + await settleCapacityCompletion() + expect((await nextMetrics(iterator)).contextWindow).toBe(64_000) + + await agentsFiber.dispose() + const refresh = nextMetrics(iterator).then( + metrics => ({ kind: 'metrics' as const, metrics }), + () => ({ kind: 'error' as const }), + ) + const outcome = await Promise.race([ + refresh, + new Promise<{ kind: 'idle' }>((resolve) => { + setImmediate(() => { resolve({ kind: 'idle' }) }) + }), + ]) + + controller.abort() + await refresh + await iterator.return?.() + detachAgent() + lifecycle.detach() + await ctx.fiber.dispose() + expect(outcome.kind).toBe('metrics') + if (outcome.kind === 'metrics') { + expect(outcome.metrics.contextWindow).toBeUndefined() + expect(outcome.metrics.logRevision).toBe(1) + } + }) + it.each(['agents', 'sessions'] as const)( 'drops capacity completion while %s is unavailable during unload', async (serviceName) => { From 032cd2f72dc5592ec976cfa3cd78c2aafbcb6041 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 16:19:40 +0800 Subject: [PATCH 09/47] docs: refresh event consumer graph after master merge --- docs/event-producer-consumer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fbb98faa71..2b719de804 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -67,7 +67,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `connection/reset` | `runtime` (`emit`) | - | | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | -| `internal/status` | - | [`agent`](../packages/core/agent) | +| `internal/status` | - | [`agent`](../packages/core/agent), `apiproxy` | | `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | | `slots/changed` | `runtime` (`emit`) | - | | `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | From 344667cbf0c3dfd071a3f3a9ba0f32acc007f0ac Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 17:03:07 +0800 Subject: [PATCH 10/47] fix(host): cancel retired metric capacity lookups (round 7) --- packages/host/apiproxy/src/api-proxy.ts | 5 + packages/host/apiproxy/src/session-metrics.ts | 37 ++++- .../apiproxy/tests/api-proxy-models.spec.ts | 151 +++++++++++++++++- .../apiproxy/tests/session-metrics.spec.ts | 123 +++++++++++--- 4 files changed, 287 insertions(+), 29 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 149143b900..46ed485d45 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -488,6 +488,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('session/disposed', (session: Session) => { pendingMetricSessions.delete(session) }), ctx.on('internal/status', (fiber) => { if (metricsDisposed) return + if (fiber.state === FiberState.UNLOADING) { + metricsProjector.invalidateCapacities() + return + } if (fiber.state !== FiberState.ACTIVE && fiber.state !== FiberState.FAILED && fiber.state !== FiberState.DISPOSED) return @@ -499,6 +503,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ] return () => { metricsDisposed = true + metricsProjector.dispose() pendingMetricSessions.clear() for (const dispose of disposers) dispose() } diff --git a/packages/host/apiproxy/src/session-metrics.ts b/packages/host/apiproxy/src/session-metrics.ts index 1ad1f488af..1eebc7b7d8 100644 --- a/packages/host/apiproxy/src/session-metrics.ts +++ b/packages/host/apiproxy/src/session-metrics.ts @@ -26,6 +26,7 @@ interface CapacityState { epoch: number status: 'pending' | 'ready' | 'retryable' contextWindow?: number + controller?: AbortController } type CapacityTarget = Pick @@ -35,7 +36,7 @@ interface TokenMeterLike { } interface LlmLike { - resolveModelInfo(provider: string, model: string): Promise<{ + resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{ context?: { contextWindow: number } }> } @@ -89,7 +90,9 @@ function routeKeyFor(target: CapacityTarget | undefined): string | undefined { export class SessionMetricsProjector { private readonly usage = new WeakMap() private readonly capacities = new WeakMap() + private readonly pendingCapacities = new Set() private capacityEpoch = 0 + private disposed = false /** * @param ctx - Host context providing optional token-meter and LLM services. @@ -105,6 +108,13 @@ export class SessionMetricsProjector { /** Retire adapter-owned metadata and fence every resolution already in flight. */ invalidateCapacities(): void { this.capacityEpoch++ + for (const pending of this.pendingCapacities) this.abortCapacityResolution(pending) + } + + /** Permanently retire capacity projection and cancel every adapter-owned lookup. */ + dispose(): void { + this.disposed = true + this.invalidateCapacities() } /** @@ -163,6 +173,7 @@ export class SessionMetricsProjector { } private capacityFor(agent: Agent): number | undefined { + if (this.disposed) return undefined const target = this.targetFor(agent) const routeKey = routeKeyFor(target) let state = this.capacities.get(agent) @@ -170,6 +181,7 @@ export class SessionMetricsProjector { || state.routeKey !== routeKey || state.epoch !== this.capacityEpoch || state.status === 'retryable') { + this.abortCapacityResolution(state) state = { routeKey, generation: (state?.generation ?? 0) + 1, @@ -192,21 +204,42 @@ export class SessionMetricsProjector { pending.status = 'retryable' return } + const controller = new AbortController() + pending.controller = controller + this.pendingCapacities.add(pending) void Promise.resolve() - .then(() => llm.resolveModelInfo(target.provider, target.model)) + .then(() => { + controller.signal.throwIfAborted() + return llm.resolveModelInfo(target.provider, target.model, controller.signal) + }) .then( (resolved) => { + this.finishCapacityResolution(pending, controller) if (this.capacityResolutionIsStale(agent, pending)) return pending.status = 'ready' if (resolved.context !== undefined) pending.contextWindow = resolved.context.contextWindow this.onCapacityResolved(agent) }, () => { + this.finishCapacityResolution(pending, controller) if (!this.capacityResolutionIsStale(agent, pending)) pending.status = 'retryable' }, ) } + private abortCapacityResolution(pending: CapacityState | undefined): void { + if (pending === undefined || pending.controller === undefined) return + const controller = pending.controller + delete pending.controller + this.pendingCapacities.delete(pending) + controller.abort() + } + + private finishCapacityResolution(pending: CapacityState, controller: AbortController): void { + this.pendingCapacities.delete(pending) + if (pending.controller === controller) delete pending.controller + } + private capacityResolutionIsStale(agent: Agent, pending: CapacityState): boolean { if (pending.epoch !== this.capacityEpoch) return true if (this.capacities.get(agent)?.generation !== pending.generation) return true diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 758669f5db..7b2595e528 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -65,7 +65,10 @@ class CatalogAdapter extends LlmAdapter { } class DeferredCatalogAdapter extends CatalogAdapter { - readonly pending: PromiseWithResolvers[] = [] + readonly pending: { + result: PromiseWithResolvers + signal: AbortSignal | undefined + }[] = [] constructor() { super('Deferred', [ @@ -73,16 +76,20 @@ class DeferredCatalogAdapter extends CatalogAdapter { ]) } - override resolveModel(_provider: string, _model: string): Promise { + override resolveModel( + _provider: string, + _model: string, + signal?: AbortSignal, + ): Promise { const result = Promise.withResolvers() - this.pending.push(result) + this.pending.push({ result, signal }) return result.promise } resolve(index: number, contextWindow: number): void { const pending = this.pending[index] if (pending === undefined) throw new Error(`no pending resolution at index ${String(index)}`) - pending.resolve({ + pending.result.resolve({ provider: 'deferred', id: 'lifecycle-model', name: 'Lifecycle model', @@ -563,6 +570,140 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('aborts pending capacity during adapter UNLOADING and refreshes after settlement', async () => { + const ctx = await hostContext() + const retiredAdapter = new DeferredCatalogAdapter() + const releaseUnload = Promise.withResolvers() + const releaseCancellationWait = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const retiredFiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.llm.registerAdapter(['deferred'], retiredAdapter) + inner.effect( + () => () => releaseUnload.promise, + 'test: hold adapter unload', + ) + inner.effect(() => () => { + const signal = retiredAdapter.pending[0]?.signal + if (signal === undefined) throw new Error('pending capacity signal missing') + const cancellation = new Promise((resolve) => { + const finish = () => { + abortObserved.resolve(undefined) + resolve() + } + if (signal.aborted) finish() + else signal.addEventListener('abort', finish, { once: true }) + }) + return Promise.race([cancellation, releaseCancellationWait.promise]) + }, 'test: await capacity cancellation') + }, { inject: ['llm'] })) + const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-unloading')) + const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) + const api = createApiProxy(ctx, { + provider: 'deepseek', + model: 'deepseek-chat', + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + const controller = new AbortController() + const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() + const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo') + + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) }) + expect(resolveModelInfo).toHaveBeenCalledOnce() + const listSessions = vi.spyOn(ctx.sessions, 'list') + listSessions.mockClear() + const pendingFrame = iterator.next() + const disposing = retiredFiber.dispose() + try { + await vi.waitFor(() => { + expect(retiredAdapter.pending[0]?.signal?.aborted).toBe(true) + }) + await abortObserved.promise + expect(retiredFiber.state).toBe(FiberState.UNLOADING) + retiredAdapter.resolve(0, 64_000) + await settleCapacityCompletion() + expect(resolveModelInfo).toHaveBeenCalledOnce() + expect(listSessions).not.toHaveBeenCalled() + const outcome = await Promise.race([ + pendingFrame.then(() => 'frame' as const), + new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }), + ]) + expect(outcome).toBe('idle') + } finally { + releaseCancellationWait.resolve(undefined) + releaseUnload.resolve(undefined) + await disposing + } + + expect(retiredFiber.state).toBe(FiberState.DISPOSED) + const settledFrame = await pendingFrame + if (settledFrame.done || settledFrame.value.payload.type !== 'session/metrics') { + throw new Error('expected settled metrics refresh') + } + expect(settledFrame.value.payload.metrics.contextWindow).toBeUndefined() + await settleCapacityCompletion() + expect(resolveModelInfo).toHaveBeenCalledTimes(2) + + const replacementAdapter = new DeferredCatalogAdapter() + const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter) + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) }) + expect(resolveModelInfo).toHaveBeenCalledTimes(3) + replacementAdapter.resolve(0, 128_000) + await settleCapacityCompletion() + expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) + + controller.abort() + await iterator.return?.() + detachAgent() + lifecycle.detach() + await replacementFiber.dispose() + await ctx.fiber.dispose() + }) + + it('aborts pending capacity when the API proxy fiber is disposed', async () => { + const ctx = await hostContext() + const deferred = new DeferredCatalogAdapter() + ctx.llm.registerAdapter(['deferred'], deferred) + const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-api-proxy-teardown')) + const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) + const proxy = Promise.withResolvers>() + const proxyFiber = await ctx.plugin(Object.assign((inner: Context) => { + proxy.resolve(createApiProxy(inner, { + provider: 'deepseek', + model: 'deepseek-chat', + cwd: '/tmp', + workspaceRoot: '/tmp', + })) + }, { inject: ['agents', 'sessions', 'userInteraction'] })) + const api = await proxy.promise + const controller = new AbortController() + const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() + + expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) + expect(deferred.pending[0]?.signal?.aborted).toBe(false) + + await proxyFiber.dispose() + expect(deferred.pending[0]?.signal?.aborted).toBe(true) + deferred.resolve(0, 64_000) + await settleCapacityCompletion() + const pendingFrame = iterator.next() + const outcome = await Promise.race([ + pendingFrame.then(() => 'frame' as const), + new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }), + ]) + expect(outcome).toBe('idle') + + controller.abort() + await expect(pendingFrame).resolves.toMatchObject({ done: true }) + await iterator.return?.() + detachAgent() + lifecycle.detach() + await ctx.fiber.dispose() + }) + it('does not read the sessions service after its disposal status', async () => { let sessionsFiber: Fiber | undefined const ctx = await hostContext((fiber) => { sessionsFiber = fiber }) @@ -720,7 +861,7 @@ describe('Web session model selection', () => { detachAgent() lifecycle.detach() await ctx.fiber.dispose() - expect(agentReads).toBe(serviceName === 'sessions' ? 1 : 0) + expect(agentReads).toBe(0) expect(sessionReads).toBe(0) expect(outcome).toBe('idle') }, diff --git a/packages/host/apiproxy/tests/session-metrics.spec.ts b/packages/host/apiproxy/tests/session-metrics.spec.ts index 3b3653aad8..deb5f740fe 100644 --- a/packages/host/apiproxy/tests/session-metrics.spec.ts +++ b/packages/host/apiproxy/tests/session-metrics.spec.ts @@ -159,12 +159,20 @@ describe('SessionMetricsProjector', () => { it('publishes only the selected route capacity when asynchronous resolutions race', async () => { const ctx = new Context() - const resolutions = new Map void>() + const resolutions = new Map() ctx.provide('tokenMeter', { measure: () => ({ totalTokens: 35_000 }) }) ctx.provide('llm', { - resolveModelInfo(_provider: string, model: string) { + resolveModelInfo(_provider: string, model: string, signal?: AbortSignal) { return new Promise<{ context: { contextWindow: number } }>((resolve) => { - resolutions.set(model, (contextWindow) => { resolve({ context: { contextWindow } }) }) + resolutions.set(model, { + signal, + resolve(contextWindow) { + resolve({ context: { contextWindow } }) + }, + }) }) }, }) @@ -176,14 +184,17 @@ describe('SessionMetricsProjector', () => { expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() await vi.waitFor(() => { expect(resolutions.has('alpha')).toBe(true) }) + expect(resolutions.get('alpha')?.signal?.aborted).toBe(false) current = { provider: 'test', model: 'beta' } expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + expect(resolutions.get('alpha')?.signal?.aborted).toBe(true) await vi.waitFor(() => { expect(resolutions.has('beta')).toBe(true) }) + expect(resolutions.get('beta')?.signal?.aborted).toBe(false) - resolutions.get('alpha')?.(64_000) + resolutions.get('alpha')?.resolve(64_000) await Promise.resolve() expect(resolved).not.toHaveBeenCalled() - resolutions.get('beta')?.(128_000) + resolutions.get('beta')?.resolve(128_000) await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) expect(projector.snapshot(session, attached)).toMatchObject({ contextTokens: 35_000, @@ -225,17 +236,74 @@ describe('SessionMetricsProjector', () => { expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) }) - it('invalidates same-route capacity and fences the prior epoch in flight', async () => { + it('aborts every active capacity on invalidation and resolves fresh generations', async () => { const ctx = new Context() - const attempts: PromiseWithResolvers<{ context: { contextWindow: number } }>[] = [] + const attempts: { + result: PromiseWithResolvers<{ context: { contextWindow: number } }> + signal: AbortSignal | undefined + }[] = [] ctx.provide('llm', { - resolveModelInfo() { - const attempt = Promise.withResolvers<{ context: { contextWindow: number } }>() - attempts.push(attempt) - return attempt.promise + resolveModelInfo(_provider: string, _model: string, signal?: AbortSignal) { + const result = Promise.withResolvers<{ context: { contextWindow: number } }>() + attempts.push({ result, signal }) + return result.promise }, }) - const session = new Session(SessionId('capacity-invalidation')) + const firstSession = new Session(SessionId('capacity-invalidation-first')) + const secondSession = new Session(SessionId('capacity-invalidation-second')) + const firstAgent = agent(firstSession) + const secondAgent = agent(secondSession) + const resolved = vi.fn() + const projector = new SessionMetricsProjector( + ctx, + () => ({ provider: 'test', model: 'alpha' }), + resolved, + ) + + expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined() + expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(attempts).toHaveLength(2) }) + expect(attempts.map(attempt => attempt.signal?.aborted)).toEqual([false, false]) + + projector.invalidateCapacities() + expect(attempts.map(attempt => attempt.signal?.aborted)).toEqual([true, true]) + attempts[0]?.result.resolve({ context: { contextWindow: 32_000 } }) + attempts[1]?.result.resolve({ context: { contextWindow: 64_000 } }) + await settleAsyncWork() + expect(resolved).not.toHaveBeenCalled() + + expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined() + expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBeUndefined() + await vi.waitFor(() => { expect(attempts).toHaveLength(4) }) + expect(attempts.slice(2).map(attempt => attempt.signal?.aborted)).toEqual([false, false]) + attempts[2]?.result.resolve({ context: { contextWindow: 128_000 } }) + attempts[3]?.result.resolve({ context: { contextWindow: 256_000 } }) + await vi.waitFor(() => { expect(resolved).toHaveBeenCalledTimes(2) }) + expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBe(128_000) + expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBe(256_000) + + projector.dispose() + expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined() + expect(attempts).toHaveLength(4) + }) + + it('skips adapter work invalidated before its deferred invocation', async () => { + const ctx = new Context() + const attempts: { + result: PromiseWithResolvers<{ context: { contextWindow: number } }> + signal: AbortSignal | undefined + }[] = [] + const resolveModelInfo = vi.fn(( + _provider: string, + _model: string, + signal?: AbortSignal, + ) => { + const result = Promise.withResolvers<{ context: { contextWindow: number } }>() + attempts.push({ result, signal }) + return result.promise + }) + ctx.provide('llm', { resolveModelInfo }) + const session = new Session(SessionId('capacity-pre-invocation-invalidation')) const attached = agent(session) const resolved = vi.fn() const projector = new SessionMetricsProjector( @@ -245,26 +313,34 @@ describe('SessionMetricsProjector', () => { ) expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(attempts).toHaveLength(1) }) projector.invalidateCapacities() - attempts[0]?.resolve({ context: { contextWindow: 64_000 } }) await settleAsyncWork() + expect(resolveModelInfo).not.toHaveBeenCalled() expect(resolved).not.toHaveBeenCalled() expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(attempts).toHaveLength(2) }) - attempts[1]?.resolve({ context: { contextWindow: 128_000 } }) + await vi.waitFor(() => { expect(attempts).toHaveLength(1) }) + expect(attempts[0]?.signal?.aborted).toBe(false) + attempts[0]?.result.resolve({ context: { contextWindow: 128_000 } }) await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) }) it('starts a fresh capacity generation when an unavailable route returns', async () => { const ctx = new Context() - const resolutions: ((contextWindow: number) => void)[] = [] + const resolutions: { + signal: AbortSignal | undefined + resolve(contextWindow: number): void + }[] = [] ctx.provide('llm', { - resolveModelInfo() { + resolveModelInfo(_provider: string, _model: string, signal?: AbortSignal) { return new Promise<{ context: { contextWindow: number } }>((resolve) => { - resolutions.push((contextWindow) => { resolve({ context: { contextWindow } }) }) + resolutions.push({ + signal, + resolve(contextWindow) { + resolve({ context: { contextWindow } }) + }, + }) }) }, }) @@ -278,13 +354,16 @@ describe('SessionMetricsProjector', () => { expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() await vi.waitFor(() => { expect(resolutions).toHaveLength(1) }) current = undefined - resolutions[0]?.(64_000) - await vi.waitFor(() => { expect(targetFor).toHaveBeenCalledTimes(2) }) + expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() + expect(resolutions[0]?.signal?.aborted).toBe(true) + resolutions[0]?.resolve(64_000) + await settleAsyncWork() expect(resolved).not.toHaveBeenCalled() current = { provider: 'test', model: 'alpha' } expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() await vi.waitFor(() => { expect(resolutions).toHaveLength(2) }) - resolutions[1]?.(128_000) + expect(resolutions[1]?.signal?.aborted).toBe(false) + resolutions[1]?.resolve(128_000) await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) }) From 35b9c454e59643f23ff33c57ffb595e8c75d4075 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 18:35:39 +0800 Subject: [PATCH 11/47] refactor(web): publish transient model request capacity (round 1) --- ...8-host-owned-web-session-metrics.i18n.yaml | 4 +- ...26-07-28-host-owned-web-session-metrics.md | 18 +- ...07-28-host-owned-web-session-metrics.zh.md | 18 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/cordis-catalog/events.md | 57 +- docs/cordis-catalog/services.md | 6 +- .../llm-streaming.i18n.yaml | 4 +- docs/core-data-structures/llm-streaming.md | 8 +- docs/core-data-structures/llm-streaming.zh.md | 8 +- docs/event-producer-consumer.md | 35 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/sessions/conversation.ts | 17 +- .../runtime/src/client/sessions/session.ts | 25 +- packages/client/runtime/tests/session.spec.ts | 59 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/StatsLine.tsx | 2 +- .../tests/chat-stats-bash-sample.spec.tsx | 20 + .../cordis/tool-cordis/src/api-catalog.ts | 13 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 19 +- .../tests/request-reconstruction.spec.ts | 102 ++- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/types.ts | 24 + .../core/scope/src/scoped-events.generated.ts | 1 + packages/core/scope/tests/invariant.spec.ts | 1 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 83 +-- .../host/apiproxy/src/api/events.schema.ts | 9 + packages/host/apiproxy/src/api/events.ts | 16 + .../host/apiproxy/src/api/sessions.schema.ts | 3 +- packages/host/apiproxy/src/api/sessions.ts | 14 +- packages/host/apiproxy/src/session-metrics.ts | 145 +--- .../tests/api-proxy-model-request.spec.ts | 104 +++ .../apiproxy/tests/api-proxy-models.spec.ts | 659 +----------------- .../host/apiproxy/tests/rpc-schemas.spec.ts | 31 +- .../apiproxy/tests/session-metrics.spec.ts | 381 +--------- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 4 +- packages/llm/llm/README.zh.md | 4 +- packages/llm/llm/src/index.ts | 128 +++- packages/llm/llm/tests/service.spec.ts | 34 + scripts/gen-cordis-catalog.ts | 1 + 54 files changed, 765 insertions(+), 1352 deletions(-) create mode 100644 packages/host/apiproxy/tests/api-proxy-model-request.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml index 95add14bf5..100837813e 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.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/architecture/2026-07-28-host-owned-web-session-metrics.md -2026-07-28-host-owned-web-session-metrics.md: 04381c7443491fd9de101a87713fa5c183800d0e -2026-07-28-host-owned-web-session-metrics.zh.md: 6ad06ea61508d3f0703c19bc7c9f14969f119334 +2026-07-28-host-owned-web-session-metrics.md: e37a7635cbdae65162308bf8a3a498b5071a4910 +2026-07-28-host-owned-web-session-metrics.zh.md: fd3e50c8cf8c0336a8cb2cd55f6629419bb8ad23 diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md index 04381c7443..e37a7635cb 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md +++ b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md @@ -6,17 +6,19 @@ English | [中文](2026-07-28-host-owned-web-session-metrics.zh.md) ## Problem -A Web stats line derived from the currently loaded conversation nodes is window-dependent under pagination. Compaction can replace visible content without preserving historical usage, and route changes leave the browser without an authoritative context capacity. Cache-write tokens also risk being folded into a cache-hit formula whose denominator has different semantics. +A Web stats line derived from the currently loaded conversation nodes is window-dependent under pagination. Compaction can replace visible content without preserving historical usage, while the selected model does not prove that a request used its route or capacity. Cache-write tokens also risk being folded into a cache-hit formula whose denominator has different semantics. ## Decision The Host owns one session-level metrics projection. It incrementally folds the complete durable event log, keys settled usage by `(turn, step)`, and replaces an earlier usage record for the same key instead of double-counting chunk and message forms. Uncached input, output, cache reads, and cache writes remain four disjoint cumulative buckets. Compaction can change the current prompt surface without erasing historical usage. -Current context pressure is a separate point-in-time value from `tokenMeter.measure(session).totalTokens`. Capacity comes only from `llm.resolveModelInfo(provider, model).context.contextWindow` for the agent's selected route. A route change immediately publishes metrics with capacity absent, then publishes the resolved capacity behind a route generation fence; stale metadata cannot label the new route. +Current context pressure is the point-in-time `tokenMeter.measure(session).totalTokens`. Capacity instead belongs to the latest model request observed by the current live mux connection. `LlmService.prepareCall()` retains the context metadata obtained by the exact lookup that also validates reasoning/defaults, and the loop publishes it through one contained `agent/model-request` notification only after the final route has a successfully constructed stream handle. Failed or aborted iteration still counts as a dispatched request; preparation and synchronous construction failures do not. -The tail `session.history` response carries the projection, while older pages omit it. Live changes use `session/metrics` mux frames. Both forms carry a durable-log revision and a projection revision; the client accepts only nondecreasing revisions, preserves metrics across older-page prepend, and clears them at a new subscription baseline. Missing measurement or metadata stays absent. +The tail `session.history` response carries durable usage and pressure, while older pages omit them. Live changes use `session/metrics` mux frames. Both forms carry a durable-log revision and a projection revision; the client accepts only nondecreasing revisions and preserves metrics across older-page prepend. -The Web stats line treats the projection as its sole token source. It renders uncached input, output, and cache reads separately, computes cache hit as `cacheRead / (uncachedInput + cacheRead)`, and shows current context as a percentage of the exact route capacity. Cache writes never enter that percentage. Visible nodes continue to supply only turn and step counts. +ApiProxy forwards each notification as a distinct `session/model-request` frame only to mux connections already open when dispatch occurs. It never places the frame in `session.history` or a subscription baseline. The client retains that connection-local capacity across ordinary metrics updates, replaces or explicitly clears it on the next observed request, and clears it on `session/subscribed`; reconnect, restore, and a new subscription therefore start unknown until another request is observed. + +The Web stats line treats the durable projection plus live capacity overlay as its sole token source. It renders uncached input, output, and cache reads separately, computes cache hit as `cacheRead / (uncachedInput + cacheRead)`, and shows current context as a percentage only when the current connection observed a capacity. Cache writes never enter that percentage. Visible nodes continue to supply only turn and step counts. ## Alternatives considered @@ -26,10 +28,12 @@ The Web stats line treats the projection as its sole token source. It renders un **Reuse one total-token field for cache hit.** Cache reads, cache writes, and uncached input represent distinct provider accounting buckets; combining them would make the displayed rate misleading. -**Keep the previous capacity until the new route resolves.** The old number would temporarily claim the wrong selected model. An explicit unknown state is honest and generation-safe. +**Query the selected route before dispatch.** Selection may never produce a request, and a second metadata lookup can race the registration-bound lookup that actually validates and dispatches the call. + +**Persist or replay the latest request capacity.** That would make a former request look current on reconnect or restore even though the new connection observed no request. The denominator is deliberately live and opportunistic. ## Consequences -Token totals remain stable across pagination, replay, compaction, and browser reconnect. The client stores a small detached projection instead of scanning the conversation window, and the status row remains readable for large histories through compact number formatting. +Token totals remain stable across pagination, replay, compaction, and browser reconnect. The client stores a small detached durable projection plus one connection-local denominator instead of scanning the conversation window, and the status row remains readable for large histories through compact number formatting. -The Host performs one incremental log fold per session and schedules live projection updates only for usage, request-header, or surface-changing events; text and reasoning deltas do not publish metrics. Exact capacity resolution is asynchronous and may briefly render as unknown. Deployments without a token meter or model context metadata retain the row and label the unavailable value instead of fabricating one. +The Host performs one incremental log fold per session and schedules durable projection updates only for usage, request-header, or surface-changing events; text and reasoning deltas do not publish metrics. A new connection omits the percentage until it observes a request with context metadata. A later request without metadata clears the denominator, while deployments without a token meter still retain the durable counters and label context unavailable instead of fabricating pressure. diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md index 6ad06ea615..fd3e50c8cf 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md @@ -6,17 +6,19 @@ Status: implemented ## 问题 -Web 统计行若根据当前加载的会话节点推导指标,其结果会随分页窗口变化。压缩(compaction)可以替换可见内容,却无法保留历史用量;路由变更会让浏览器缺少权威的上下文容量。缓存写入 token 还可能被计入缓存命中率公式,而该公式的分母具有不同语义。 +Web 统计行若根据当前加载的会话节点推导指标,其结果会随分页窗口变化。压缩(compaction)可以替换可见内容,却无法保留历史用量;所选模型也不能证明某次请求实际采用了该模型的路由或容量。缓存写入 token 还可能被计入缓存命中率公式,而该公式的分母具有不同语义。 ## 决策 Host 拥有一项会话级指标投影。它以增量方式归并完整的持久事件日志,按 `(turn, step)` 标识已结算用量;同一标识再次出现时,会替换较早的用量记录,而不会重复统计分片和消息两种形态。未缓存输入、输出、缓存读取与缓存写入保持为四个彼此独立的累计计数项。压缩可以改变当前提示词表层,但不会抹除历史用量。 -当前上下文压力是一个独立的即时值,取自 `tokenMeter.measure(session).totalTokens`。容量仅来自 `llm.resolveModelInfo(provider, model).context.contextWindow`,并对应 agent(智能体)所选的路由。路由变更时,Host 会立即发布不带容量的指标,再通过路由代际围栏发布解析出的容量;陈旧元数据无法标记新的路由。 +当前上下文压力是即时的 `tokenMeter.measure(session).totalTokens`。容量则属于当前实时 mux 连接观察到的最新模型请求。`LlmService.prepareCall()` 会保留同一次精确查询取得的上下文元数据,该查询也负责校验推理设置与默认值;仅在最终路由的流句柄成功构造后,循环才会通过一条失败会被收容的 `agent/model-request` 通知发布这些元数据。后续迭代失败或中止仍算作已分派请求;准备阶段失败和同步构造失败则不算。 -`session.history` 尾页响应携带该投影,较早页面则省略它。实时变更使用 `session/metrics` mux 帧。两种形式都携带持久日志修订号和投影修订号;客户端只接受不减小的修订号,在向前加载较早页面时保留指标,并在建立新的订阅基线时将其清除。测量值或元数据缺失时,对应字段保持缺失。 +`session.history` 尾页响应携带持久用量与压力,较早页面则省略这两项。实时变更使用 `session/metrics` mux 帧。两种形式都携带持久日志修订号和投影修订号;客户端只接受不减小的修订号,并在向前加载较早页面时保留指标。 -Web 统计行把该投影视为唯一的 token 数据来源。它分别呈现未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率,并把当前上下文显示为精确路由容量的百分比。缓存写入绝不计入缓存命中率。可见节点仍然只提供轮次和步骤计数。 +ApiProxy 只把每条通知作为独立的 `session/model-request` 帧转发给分派发生时已经打开的 mux 连接。它绝不会把该帧放入 `session.history` 或订阅基线。客户端会在普通指标更新期间保留这项连接本地容量,在观察到下一次请求时替换或显式清除它,并在收到 `session/subscribed` 时将其清除;因此,重连、恢复和新订阅都会从未知容量开始,直到观察到另一次请求。 + +Web 统计行把持久投影与实时容量覆盖层视为唯一的 token 数据来源。它分别呈现未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率,并且只有当前连接观察到容量时,才把当前上下文显示为该容量的百分比。缓存写入绝不计入缓存命中率。可见节点仍然只提供轮次和步骤计数。 ## 备选方案 @@ -26,10 +28,12 @@ Web 统计行把该投影视为唯一的 token 数据来源。它分别呈现未 **为缓存命中率复用单一的 token 总数字段。** 缓存读取、缓存写入与未缓存输入是提供方记账中的不同计数项;将它们合并会使显示的比率产生误导。 -**在新路由解析完成前保留旧容量。** 旧数值会在短时间内错误标示所选模型。显式的「未知」状态能如实反映情况,并避免跨代串扰。 +**在分派前查询所选路由。** 选择操作可能永远不会产生请求;第二次元数据查询还可能与实际校验并分派调用的、绑定注册项的查询发生竞态。 + +**持久化或回放最新请求的容量。** 即使新连接没有观察到任何请求,这也会让先前请求在重连或恢复后显得仍然有效。该分母刻意只采用实时且恰好可得的数据。 ## 后果 -token 总量在分页、回放、压缩和浏览器重连期间保持稳定。客户端存储一项小型脱耦投影,无需扫描会话窗口;状态行采用紧凑数字格式,因此在较长的历史记录中仍然清晰易读。 +token 总量在分页、回放、压缩和浏览器重连期间保持稳定。客户端存储一项小型、脱耦的持久投影与一个连接本地分母,无需扫描会话窗口;状态行采用紧凑数字格式,因此在较长的历史记录中仍然清晰易读。 -Host 为每个会话执行一次增量日志归并,仅为用量事件、请求头事件或表层变更事件调度实时投影更新;文本与推理(reasoning)增量不会发布指标。精确容量解析为异步操作,因此可能短暂显示「未知」。未部署 token 计量器或缺少模型上下文元数据时,系统仍保留该行,并标示不可用的值,而不会虚构数据。 +Host 为每个会话执行一次增量日志归并,仅为用量事件、请求头事件或表层变更事件调度持久投影更新;文本与推理(reasoning)增量不会发布指标。新连接在观察到带上下文元数据的请求之前不会显示百分比。后续不带元数据的请求会清除该分母;未部署 token 计量器时,系统仍保留持久计数器,并把上下文标示为不可用,而不会虚构压力值。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e56a3f91bb..cfe3106723 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 docs/architecture.md -architecture.md: 054985ac5ea32a44b9daca3c1abfd58dcdc5d897 -architecture.zh.md: 84876faf2ae27069ba8bd026bcfbc56e32f65574 +architecture.md: 52072633a0e81afce63c5e162dd1b0af7f6486ca +architecture.zh.md: f0122ece146c17366aa316cfb4ea4196a1db74bd diff --git a/docs/architecture.md b/docs/architecture.md index 054985ac5e..52072633a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,7 @@ forever: assemble system prompt and tool schemas snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound) + agent/request (config only) -> prepare reasoning/default + context under turn signal -> log request/header -> construct llm/stream (frozen, registration-bound) -> agent/model-request (live, contained) -> iterate 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: @@ -151,7 +151,7 @@ Log-only events may sit between turns. Owners append through `Session`, flushing Messages use typed blocks from merge-extensible `ContentBlockMap`; the pattern also types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New blocks coordinate adapters, UI, compaction, token metering, and persistence; replay measurements live in [token-meter.md](core-data-structures/token-meter.md). -Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is one provider attempt; adapters report normalized failure facts, and a handling `agent/request-error` plugin returns a retry action. The loop logs chunks, successful provenance, and replay state. Remote adapters use per-read idle watchdogs. Replay crosses routes only through a shared adapter instance ([contract](core-data-structures/llm-streaming.md)). +Streaming uses raw chunks and `BlockAssembler`. After final-stream construction, the loop emits contained, non-durable, non-replayed `agent/model-request` metadata. Adapters normalize failures; `agent/request-error` may retry. Remote adapters use per-read idle watchdogs. Replay crosses routes only through a shared adapter ([contract](core-data-structures/llm-streaming.md)). ## Extension And Composition diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 84876faf2a..f0122ece14 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -92,7 +92,7 @@ forever: assemble system prompt and tool schemas snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound) + agent/request (config only) -> prepare reasoning/default + context under turn signal -> log request/header -> construct llm/stream (frozen, registration-bound) -> agent/model-request (live, contained) -> iterate 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: @@ -151,7 +151,7 @@ idle inject: 消息使用从可合并扩展的 `ContentBlockMap` 派生的类型化块;同一模式也为 `MessageSource`、`FinishReason`、`TurnTrigger` 和 `TurnEndReason` 定义类型。新增块会协调适配器、UI、压缩、token 计量和持久化;回放计量见 [token-meter.md](core-data-structures/token-meter.md)。 -流式输出使用原始分片和 `BlockAssembler`。每次 `LlmAdapter.stream()` 调用代表一次提供方尝试;适配器报告标准化的故障事实,负责处理的 `agent/request-error` 插件会返回重试动作。循环会记录分片、成功结果的来源信息和回放状态。远程适配器使用逐次读取空闲看门狗。回放仅通过共用的适配器实例跨路由传递([契约](core-data-structures/llm-streaming.md))。 +流式输出使用原始分片和 `BlockAssembler`。最终流构造完成后,循环会发出 `agent/model-request` 元数据;该通知的失败会被收容,元数据不会持久化或回放。适配器会规范化故障;`agent/request-error` 可以重试。远程适配器使用逐次读取空闲看门狗。回放仅通过共用适配器跨路由传递([契约](core-data-structures/llm-streaming.md))。 ## 扩展与组合 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ca0e9b82fc..e8e67da781 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:257`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:266`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:423`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:447`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:296`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueued id r Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -162,7 +162,32 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) + +### `agent/model-request` — emit + +One model request constructed its final stream handle and is about to iterate it. This live notification is not durable or replayed; failed or aborted iteration still has a dispatch, while preparation and synchronous stream-construction failures do not. Listener failures are contained and cannot affect the request. + +```ts cordis-catalog +/** + * One model request constructed its final stream handle and is about to + * iterate it. This live notification is not durable or replayed; failed or + * aborted iteration still has a dispatch, while preparation and + * synchronous stream-construction failures do not. Listener failures are + * contained and cannot affect the request. + * @param agent - the agent dispatching the model request. + * @param turn - the open turn number. + * @param step - the request's step number. + * @param request - final route plus registration-bound context capacity. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/model-request'(this: Scoped, agent: Agent, turn: number, step: number, request: AgentModelRequest): void +``` + +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:386`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -186,7 +211,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -210,7 +235,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:372`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -240,7 +265,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:405`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -262,7 +287,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -287,7 +312,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:410`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:434`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -307,7 +332,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:275`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -331,7 +356,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -357,7 +382,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -548,7 +573,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:56`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:57`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 19d7d7765d..9d00ef37a0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -780,14 +780,16 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise +stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable ``` Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:189`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:194`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 9f1e4f440a..6206f864cf 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.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 docs/core-data-structures/llm-streaming.md -llm-streaming.md: db46deee28cd053d034f889eb7625c9f222b418b -llm-streaming.zh.md: fbff50bf1f3b86afd313c2d5020c15dd88e0b449 +llm-streaming.md: 89628ebb96a2e8eec5209635cd92859427df4a8d +llm-streaming.zh.md: 9c8fcc1f24b970f3a7cdd7cd08d9ef3b934b4543 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index db46deee28..89628ebb96 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -161,21 +161,25 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, and to retain detached context metadata from that exact lookup. Its optional observer runs after a final stream handle is constructed and before adapter iteration. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Detached context metadata resolved with the registration-bound call. */ + readonly context?: LlmModelContext /** * Dispatch this call once through the registration captured during * preparation. The request's call-config fields must match {@link config}; * reuse or mismatch fails with `INVALID_PREPARED_CALL`. * @param options - fully assembled request carrying the prepared config. + * @param onDispatched - contained Agent-loop notification hook invoked after + * a stream handle is constructed and before its adapter is iterated. * @returns the chunk stream, including the `llm/stream` waterfall. */ - stream(options: GenerateOptions): AsyncIterable + stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable } ``` diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index fbff50bf1f..9c8fcc1f24 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -161,21 +161,25 @@ declare class BlockAssembler { ## seam -`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,并保留来自同一次精确查询的分离上下文元数据。其可选观察器在最终流句柄构造完成后、适配器开始迭代前运行。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Detached context metadata resolved with the registration-bound call. */ + readonly context?: LlmModelContext /** * Dispatch this call once through the registration captured during * preparation. The request's call-config fields must match {@link config}; * reuse or mismatch fails with `INVALID_PREPARED_CALL`. * @param options - fully assembled request carrying the prepared config. + * @param onDispatched - contained Agent-loop notification hook invoked after + * a stream handle is constructed and before its adapter is iterated. * @returns the chunk stream, including the `llm/stream` waterfall. */ - stream(options: GenerateOptions): AsyncIterable + stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2b719de804..b71fdb13f9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:140`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:423`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:336`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:381`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:410`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:396`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:257`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:266`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:447`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/model-request` | `emit` | [`packages/core/agent/src/types.ts:386`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:372`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:405`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:434`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:275`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | @@ -30,7 +31,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:56`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:57`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | @@ -67,7 +68,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `connection/reset` | `runtime` (`emit`) | - | | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | -| `internal/status` | - | [`agent`](../packages/core/agent), `apiproxy` | +| `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | | `slots/changed` | `runtime` (`emit`) | - | | `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index a14a451b52..865b83389e 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.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 packages/client/runtime/README.md -README.md: 935bb4908fc54fc629574963e28dc0ddcfb89a6c -README.zh.md: 85d444ee5d973bb989232ecaf00f2312d3195c8d +README.md: bc1149644d6112ca82c9a27912d1a58351cf84a5 +README.zh.md: 993625614061e819495b25f0851156ea20c62601 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 935bb4908f..bc1149644d 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries two Host-owned full-log projections. `todos` comes from the tail history page, survives older-page prepend, and follows live `todo/write` events. `metrics` comes from tail history and live `session/metrics` frames, survives older-page prepend, and accepts only nondecreasing log and projection revisions; a subscription baseline clears it before replay so a new stream generation can restart revisions safely. Missing metrics remain `null` rather than being inferred from the visible node window. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries two Host-owned full-log projections. `todos` comes from the tail history page, survives older-page prepend, and follows live `todo/write` events. Durable `metrics` comes from tail history and live `session/metrics` frames, survives older-page prepend, and accepts only nondecreasing log and projection revisions. The Session separately retains capacity from the latest `session/model-request` observed on its current mux connection and overlays it onto metrics across ordinary usage/pressure updates. A later request replaces or clears that value, while `session/subscribed` clears both metrics ordering and capacity; reconnect, restore, and a new subscription therefore show no percentage until another request is observed. Missing metrics remain `null` rather than being inferred from the visible node window. ## Workspace and Session lists diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 85d444ee5d..9936256140 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带两项由 Host 拥有的完整日志投影。`todos` 来自 history 尾页,在向前加载较早页面时保留,并随实时 `todo/write` 事件更新。`metrics` 来自 history 尾页和实时 `session/metrics` 帧,在向前加载较早页面时保留,并且只接受日志修订号与投影修订号均不减小的数据;订阅基线会在回放前将其清除,使新的流代次可以安全地从头开始计数修订号。缺失的 metrics 保持为 `null`,而不是根据可见节点窗口推断。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带两项由 Host 拥有的完整日志投影。`todos` 来自 history 尾页,在向前加载较早页面时保留,并随实时 `todo/write` 事件更新。持久 `metrics` 来自 history 尾页和实时 `session/metrics` 帧,在向前加载较早页面时保留,并且只接受日志修订号与投影修订号均不减小的数据。Session 另行保留当前 mux 连接观察到的最新 `session/model-request` 容量,并在普通用量/压力更新期间把它覆盖到 metrics 上。后续请求会替换或清除该值,`session/subscribed` 则同时清除指标顺序状态与容量;因此,重连、恢复和新订阅都不会显示百分比,直到观察到另一次请求。缺失的 metrics 保持为 `null`,而不是根据可见节点窗口推断。 ## Workspace 与 Session 列表 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 202ddf7df2..4ced165e7a 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -12,6 +12,15 @@ import type { PendingInteraction } from './pending.ts' export type { TodoItem } +/** + * Durable Host metrics with the latest capacity observed on this live mux + * connection overlaid for presentation. + */ +export interface ConversationMetrics extends SessionMetrics { + /** Latest dispatched-request capacity; absent until observed or after reset/clear. */ + contextWindow?: number +} + /** Assistant content blocks sorted by what the UI cares about * (text body / collapsible reasoning / tool-call card head / other fallback). */ export type AssistantBlock = @@ -247,9 +256,9 @@ export interface ConversationSnapshot { * write (last write wins); empty = the log holds no plan. */ todos: readonly TodoItem[] /** - * Host-owned cumulative usage and current-context projection. Independent - * of `nodes` pagination; null until a tail response or live metrics frame - * supplies a current value. + * Host-owned cumulative usage/current pressure with live mux-local capacity + * overlaid. Independent of `nodes` pagination; null until a tail response or + * live metrics frame supplies a current durable value. */ - metrics: SessionMetrics | null + metrics: ConversationMetrics | null } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index a8eecd1bac..08ed26f2a6 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -12,7 +12,7 @@ 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, + CodeSubCall, ComposerPhase, ConversationMetrics, ConversationNode, ConversationSnapshot, OpenState, PromptError, QueuedMessage, RunningToolCall, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' @@ -102,8 +102,10 @@ export class Session implements ObservableSnapshot { /** Current whole-list todo/write projection: each tail history response replaces it (an omitted * field is the authoritative empty list) and every live write overwrites it. */ private todos: readonly TodoItem[] = [] - /** Host-owned metrics projection; ordering resets on each subscribed baseline. */ - private metrics: SessionMetrics | null = null + /** Host-owned metrics with current-connection request capacity overlaid. */ + private metrics: ConversationMetrics | null = null + /** Latest capacity observed on this mux connection, independent of durable metrics arrival. */ + private contextWindow: number | undefined /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends * copy-on-write the per-parent array so published snapshot references never mutate. */ private codeDispatches = new Map() @@ -367,6 +369,7 @@ export class Session implements ObservableSnapshot { this.queueRev++ this.notifier.markDirty() } + this.contextWindow = undefined if (this.metrics !== null) { this.metrics = null this.notifier.markDirty() @@ -377,6 +380,18 @@ export class Session implements ObservableSnapshot { this.installMetrics(frame.metrics) return } + case 'session/model-request': { + if (this.contextWindow === frame.contextWindow) return + this.contextWindow = frame.contextWindow + if (this.metrics !== null) { + const { contextWindow: _previous, ...durable } = this.metrics + this.metrics = frame.contextWindow === undefined + ? durable + : { ...durable, contextWindow: frame.contextWindow } + this.notifier.markDirty() + } + return + } case 'approval/requested': { const { type: _type, sessionId: _sid, ...payload } = frame this.mint(new PendingWait('approval', rpcId, this.sessionId, payload, m => this.api.respond(m))) @@ -800,7 +815,9 @@ export class Session implements ObservableSnapshot { || metrics.projectionRevision < current.projectionRevision ) ) return - this.metrics = metrics + this.metrics = this.contextWindow === undefined + ? metrics + : { ...metrics, contextWindow: this.contextWindow } this.notifier.markDirty() } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 6d3ce893c7..4c93475b79 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -51,7 +51,6 @@ function metrics( cacheReadTokens: 90, cacheWriteTokens: 3, contextTokens: 35, - contextWindow: 100, ...over, } } @@ -146,7 +145,7 @@ describe('live event path', () => { expect(session.getSnapshot().nodes).toEqual(before.nodes) }) - it('orders live metrics, rejects stale projections, and clears the value at a reconnect baseline', async () => { + it('retains live capacity across metrics, replaces or clears it on requests, and resets at subscription', async () => { const { session } = await opened() const current = metrics(8, 10) session.handleMuxEnvelope('m1' as never, { @@ -156,6 +155,20 @@ describe('live event path', () => { }) expect(session.getSnapshot().metrics).toBe(current) + session.handleMuxEnvelope('request-1' as never, { + type: 'session/model-request', + sessionId: SID, + turn: 1, + step: 1, + provider: 'test', + model: 'alpha', + contextWindow: 128_000, + }) + expect(session.getSnapshot().metrics).toEqual({ + ...current, + contextWindow: 128_000, + }) + session.handleMuxEnvelope('m2' as never, { type: 'session/metrics', sessionId: SID, @@ -166,7 +179,31 @@ describe('live event path', () => { sessionId: SID, metrics: metrics(7, 11, { uncachedInputTokens: 2 }), }) - expect(session.getSnapshot().metrics).toBe(current) + expect(session.getSnapshot().metrics).toEqual({ + ...current, + contextWindow: 128_000, + }) + + const ordinaryUpdate = metrics(9, 11, { contextTokens: 40 }) + session.handleMuxEnvelope('m4' as never, { + type: 'session/metrics', + sessionId: SID, + metrics: ordinaryUpdate, + }) + expect(session.getSnapshot().metrics).toEqual({ + ...ordinaryUpdate, + contextWindow: 128_000, + }) + + session.handleMuxEnvelope('request-2' as never, { + type: 'session/model-request', + sessionId: SID, + turn: 2, + step: 1, + provider: 'test', + model: 'without-capacity', + }) + expect(session.getSnapshot().metrics).toEqual(ordinaryUpdate) session.handleMuxEnvelope('sub' as never, { type: 'session/subscribed', @@ -174,13 +211,25 @@ describe('live event path', () => { lastSeq: 5, }) expect(session.getSnapshot().metrics).toBeNull() + session.handleMuxEnvelope('request-3' as never, { + type: 'session/model-request', + sessionId: SID, + turn: 3, + step: 1, + provider: 'test', + model: 'beta', + contextWindow: 256_000, + }) const nextGeneration = metrics(0, 10, { contextTokens: 20 }) - session.handleMuxEnvelope('m4' as never, { + session.handleMuxEnvelope('m5' as never, { type: 'session/metrics', sessionId: SID, metrics: nextGeneration, }) - expect(session.getSnapshot().metrics).toBe(nextGeneration) + expect(session.getSnapshot().metrics).toEqual({ + ...nextGeneration, + contextWindow: 256_000, + }) }) it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 33ec0b703d..7296e8b314 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: 0674a596353aac024824489e5ca25615c7426bcd -README.zh.md: 65d3eba678b7190d97244a54c629b78c7d24b8c0 +README.md: 1f47260f9034f22ba560552e5a99b938bf14ed6d +README.zh.md: 7d9a9d2ab95d93668de216d64eee704bcc569547 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 0674a59635..1f47260f90 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ Per-session UI state for selection and the active view lives in the declared cha The composer bar declares session-scoped single seats for `'conversation.input.plan'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. -The chat stats line reads durable token counters and current-context pressure only from `ConversationSnapshot.metrics`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy against the selected route's exact capacity. Missing host data is labeled unknown, never reconstructed from a paged window. +The chat stats line reads durable token counters/current pressure plus the runtime's connection-local capacity overlay only from `ConversationSnapshot.metrics`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy only after the current mux connection observes a model request with capacity. Before that request, after reconnect/restore/new subscription, or after a request without capacity, the percentage is omitted and context is labeled unknown rather than queried ahead or reconstructed from history. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 65d3eba678..7d9a9d2ab9 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -18,7 +18,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 输入栏为 `'conversation.input.plan'` 和 `'conversation.input.model'` 声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送/停止按钮之前。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 -聊天统计行只从 `ConversationSnapshot.metrics` 读取持久的 token 计数与当前上下文压力;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并根据所选路由的精确容量显示上下文占用率。Host 数据缺失时标为「未知」,绝不根据分页窗口重建。 +聊天统计行只从 `ConversationSnapshot.metrics` 读取持久的 token 计数/当前压力,以及运行时提供的连接本地容量覆盖值;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并且只有当前 mux 连接观察到带容量的模型请求后才显示上下文占用率。在该请求之前、重连/恢复/新订阅之后,或在请求不带容量之后,系统都会省略百分比,并把上下文标为「未知」,而不会提前查询或根据历史记录重建。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 5c77b585e7..3aa36e2eb8 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -56,7 +56,7 @@ export function cacheHitPercent(metrics: SessionMetrics): number | null { /** * Current context occupancy using the TUI's integer rounding and upper clamp. - * @param metrics - Host-owned current pressure and exact route capacity. + * @param metrics - Host-owned pressure plus current-connection request capacity. * @returns occupancy percent, or null when either input is unavailable. */ export function contextPercent(metrics: SessionMetrics): number | null { diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 1f986efc7d..5aa737cd36 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -127,6 +127,26 @@ describe('StatsLine', () => { expect(emptyView.container.textContent).toBe('') }) + it('renders durable counters without a percentage before live capacity is observed', () => { + const { source } = makeSource({ + nodes: [assistant(1, 1)], + metrics: { + logRevision: 4, + projectionRevision: 1, + uncachedInputTokens: 120, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 10, + contextTokens: 8_000, + }, + }) + const view = render() + expect(view.getByText( + '120 uncached input · 20 output · 30 cache read · cache hit 20% · context unknown · 1 turns · 1 steps', + )).toBeTruthy() + expect(view.container.textContent).not.toContain('% of') + }) + it('renders honest unknowns when the host projection is missing', () => { const { source } = makeSource({ nodes: [assistant(1, 1)] }) const view = render() diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 47f8a1a54f..897935dad6 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -397,8 +397,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Resolve one call under its current adapter registration. The returned\n * one-shot handle keeps that registration across header logging and dispatch,\n * so HMR cannot combine one adapter\'s capability result with another adapter.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a prepared config and its registration-bound stream entry point.\n */', }, { - signature: 'stream(options: GenerateOptions): AsyncIterable', - jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', + signature: 'stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable', + jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @param onDispatched - contained Agent-loop notification hook invoked after\n * a stream handle is constructed and before its adapter is iterated.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', }, ], }, @@ -1048,6 +1048,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * An item entered the queued or steering inbox. `placement` is the\n * acceptance-time routing result; listeners must not reconstruct it from\n * later agent or session state.\n * @param agent - the owning agent.\n * @param message - accepted content, source, and correlation identity.\n * @param placement - resolved queued or steering placement.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'An item entered the queued or steering inbox.', }, + { + name: 'agent/model-request', + mode: 'emit', + signature: '\'agent/model-request\'(this: Scoped, agent: Agent, turn: number, step: number, request: AgentModelRequest): void', + jsDoc: '/**\n * One model request constructed its final stream handle and is about to\n * iterate it. This live notification is not durable or replayed; failed or\n * aborted iteration still has a dispatch, while preparation and\n * synchronous stream-construction failures do not. Listener failures are\n * contained and cannot affect the request.\n * @param agent - the agent dispatching the model request.\n * @param turn - the open turn number.\n * @param step - the request\'s step number.\n * @param request - final route plus registration-bound context capacity.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One model request constructed its final stream handle and is about to iterate it.', + }, { name: 'agent/prompt-submit', mode: 'waterfall', @@ -1803,7 +1810,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PreparedLlmCall', - declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly context?: LlmModelContext;\n stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable;\n}', }, { name: 'PreparedReferencedMessage', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 8652771f93..8b72206a87 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.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 packages/core/agent-loop/README.md -README.md: c12140f27aed400b0f7b4246700473e877d37632 -README.zh.md: 6394cd86f5f3241be07ef711c76079624bce1bfe +README.md: 39eaafd3abb2faef045c1a2f694d8dffe95c28b0 +README.zh.md: f8cc972e957fe95f652d54e859f8e5411288db51 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index c12140f27a..39eaafd3ab 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -62,7 +62,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. -After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently. +After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort, materialize its configured default, and retain available context metadata from that same exact-model lookup under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. After the final stream handle is constructed and before adapter iteration, the loop emits one contained live `agent/model-request` notification with turn, step, final provider/model, and optional registration-bound capacity. Preparation or synchronous stream-construction failures emit nothing; later failure or abortion remains an observed dispatch. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently. Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 6394cd86f5..f8cc972e95 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -62,7 +62,7 @@ interface Config { 每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。 -在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。 +在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终流句柄构造完成后、适配器开始迭代前,循环会发出一条失败会被收容的实时 `agent/model-request` 通知,其中包含轮次、步骤、最终提供方/模型,以及可选的、与注册项绑定的容量。准备阶段失败或同步流构造失败不会发出通知;之后即使失败或中止,该请求仍视为已观察到的分派。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。 插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 2ba2b6ab88..9c930912ba 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -487,7 +487,24 @@ export class ReactLoopAgent implements Agent { const assembler = new BlockAssembler() const chunkSeqs: number[] = [] - const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) + const onDispatched = (): void => { + emitAgentEvent( + this.loopCtx, + this, + 'agent/model-request', + turn, + step, + { + provider: request.provider, + model: request.model, + ...preparedCall?.context === undefined + ? {} + : { contextWindow: preparedCall.context.contextWindow }, + }, + ) + } + const stream = preparedCall?.stream(request, onDispatched) + ?? this.loopCtx.llm.stream(request, onDispatched) try { for await (const chunk of stream) { signal.throwIfAborted() diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 0298aaa15e..9da0e30f81 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -7,8 +7,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm' +import LlmService, { LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk, +} from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' @@ -179,6 +181,7 @@ describe('request stability across the loop', () => { provider, id: model, name: model, + context: { contextWindow: 64_000 }, reasoning: await reasoning.promise, } } @@ -189,6 +192,12 @@ describe('request stability across the loop', () => { }) const disposeFirst = ctx.llm.registerAdapter(['mock'], first) const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' }) + const dispatched: number[] = [] + ctx.on('agent/model-request', (subject, _turn, _step, request) => { + if (subject === agent && request.contextWindow !== undefined) { + dispatched.push(request.contextWindow) + } + }) send(agent, 'go') await started.promise @@ -204,6 +213,7 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ]) expect(second.requests).toHaveLength(0) + expect(dispatched).toEqual([64_000]) const headers = agent.session.events.filter(event => event.type === 'request/header') expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high')) }) @@ -308,6 +318,94 @@ describe('request stability across the loop', () => { }) }) + it('notifies one contained live model-request edge only after successful stream construction', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: 'stable base' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + let resolutions = 0 + const adapter = new class extends LlmAdapter { + override resolveModel(provider: string, model: string): Promise { + resolutions += 1 + return Promise.resolve({ + provider, + id: model, + name: model, + ...model === 'capacity' + ? { context: { contextWindow: 128_000 } } + : {}, + }) + } + + override stream(options: GenerateOptions): AsyncIterable { + if (options.model === 'sync-failure') throw new LlmError('construction failed', 'CONSTRUCTION') + if (options.model === 'async-failure') { + return { + [Symbol.asyncIterator]: () => ({ + next: () => Promise.reject(new LlmError('iteration failed', 'ITERATION')), + }), + } + } + return (async function* () { + yield* textResponse(options.model) + })() + } + }() + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(SessionId('model-request-live'), { + provider: 'mock', + model: 'capacity', + }) + const observed: { + turn: number + step: number + provider: string + model: string + contextWindow?: number + }[] = [] + ctx.on('agent/model-request', (subject) => { + if (subject === agent) throw new Error('observer failed') + }) + ctx.on('agent/model-request', (subject, turn, step, request) => { + if (subject === agent) observed.push({ turn, step, ...request }) + }) + ctx.on('agent/request', async (_subject, turn, _step, _signal, next) => ({ + ...await next(), + model: ['capacity', 'unknown', 'async-failure', 'sync-failure'][turn - 1]!, + })) + + for (const prompt of ['one', 'two', 'three', 'four']) { + send(agent, prompt) + await waitForIdle(ctx, agent) + } + + expect(observed).toEqual([ + { + turn: 1, + step: 1, + provider: 'mock', + model: 'capacity', + contextWindow: 128_000, + }, + { + turn: 2, + step: 1, + provider: 'mock', + model: 'unknown', + }, + { + turn: 3, + step: 1, + provider: 'mock', + model: 'async-failure', + }, + ]) + expect(resolutions).toBe(4) + }) + it('a compaction replace rewrites the resend, and the log explains it', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 4cd42522ca..00465d7021 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.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 packages/core/agent/README.md -README.md: bb48fd8b227484a43af8f9f9e55f8adc8b990ce6 -README.zh.md: 531db9905b3c091a5c129d31e944e4095e66123b +README.md: 304347d32df4546389ee2b45230d4e80acc60942 +README.zh.md: d9adbe20015aadbad26288d43df92f80a3f550bf diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index bb48fd8b22..304347d32d 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -48,7 +48,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. +Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while the contained `agent/model-request` notification reports a final dispatched route and optional registration-bound context capacity without becoming durable state. `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. `PromptDecision.additionalContexts` is an array so every context keeps its own source. Allowed prompt content and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 531db9905b..d9adbe2001 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -48,7 +48,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 -大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 +大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;`agent/model-request` 是失败会被收容的通知,它会报告最终已分派路由及可选的、与注册项绑定的上下文容量,但不会成为持久状态。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 `PromptDecision.additionalContexts` 是数组,因此每个上下文都保留自己的来源。获准的提示词内容与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;返回的允许决策是权威来源。 diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b61ebadb86..5aac3152b2 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -116,6 +116,16 @@ export type PromptDecision = /** Model-request failure with an optional machine-routable provider code. */ export type RequestError = Error & { code?: string } +/** Live metadata for one model request that reached adapter dispatch. */ +export interface AgentModelRequest { + /** Final registered provider route. */ + readonly provider: string + /** Final adapter-owned model id. */ + readonly model: string + /** Registration-bound context capacity when the adapter exposed one. */ + readonly contextWindow?: number +} + /** Action returned by a listener that owns model-request recovery. */ export type RequestErrorAction = { kind: 'retry' } | undefined @@ -360,6 +370,20 @@ declare module 'cordis' { * @mode waterfall */ 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise + /** + * One model request constructed its final stream handle and is about to + * iterate it. This live notification is not durable or replayed; failed or + * aborted iteration still has a dispatch, while preparation and + * synchronous stream-construction failures do not. Listener failures are + * contained and cannot affect the request. + * @param agent - the agent dispatching the model request. + * @param turn - the open turn number. + * @param step - the request's step number. + * @param request - final route plus registration-bound context capacity. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/model-request'(this: Scoped, agent: Agent, turn: number, step: number, request: AgentModelRequest): void /** * Handle a model-request failure after its failed step has closed but * before the failed turn closes. A listener returns `{ kind: 'retry' }` diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 89515bf3c2..a7bd5c93d2 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -15,6 +15,7 @@ const scopedSubjectResolvers: Readonly args[0], 'agent/inbox/discard': args => args[0], 'agent/inbox/enqueue': args => args[0], + 'agent/model-request': args => args[0], 'agent/prompt-submit': args => args[0], 'agent/request': args => args[0], 'agent/request-error': args => args[0], diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index bc1224d86b..d5cfea85ce 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -49,6 +49,7 @@ describe('scoped-dispatch invariants', () => { 'agent/step': [agent, 1, 1, signal], 'agent/prompt-submit': [agent, [], { kind: 'user' }, signal, () => Promise.resolve({ kind: 'allow' })], 'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)], + 'agent/model-request': [agent, 1, 1, { provider: 'p', model: 'm', contextWindow: 128_000 }], 'agent/request-error': [ agent, 1, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 016ad9620f..2bab687635 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: e2ba8c0e5b2620d095636503f47fac5480c6c2fa -README.zh.md: 2aae0bd683e9ea1e1fb43f73000420dc2354ee0b +README.md: d3d1711242f71832f63eb570242fdf9e149cc988 +README.zh.md: 4e52058c1795ea23a9ca8ed46b8890c85f129eb4 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index e2ba8c0e5b..d3d1711242 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -20,7 +20,9 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create` `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries session-level projections the page window cannot supply: the in-flight partial's chunk events; `todos`, the latest `todo/write` whole-list projection; and `metrics`, full-log usage deduplicated by `(turn, step)` plus current token-meter pressure and exact selected-route capacity when available. Older pages omit the session-level projections. Live `session/metrics` mux frames carry monotonic log/projection revisions, so clients reject stale frames and preserve the counters while prepending older pages. Cache reads and writes remain disjoint buckets; the cache-hit denominator is uncached input plus cache reads. +`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries session-level projections the page window cannot supply: the in-flight partial's chunk events; `todos`, the latest `todo/write` whole-list projection; and `metrics`, full-log usage deduplicated by `(turn, step)` plus current token-meter pressure. Older pages omit the session-level projections. Live `session/metrics` mux frames carry monotonic log/projection revisions, so clients reject stale frames and preserve the counters while prepending older pages. Cache reads and writes remain disjoint buckets; the cache-hit denominator is uncached input plus cache reads. + +Context capacity uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an actual request reaches dispatch. It carries turn, step, final provider/model, and optional capacity only to mux connections already open at that instant. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay prior capacity; a frame without capacity explicitly clears the earlier connection-local value. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 2aae0bd683..4e52058c17 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -20,7 +20,9 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)携带页窗口本身无法提供的会话级投影:进行中局部消息的分片事件;`todos`,即最后一次 `todo/write` 的整表投影;以及 `metrics`,即按 `(turn, step)` 去重的完整日志用量,并在可用时包含当前 token 计量压力和所选精确路由的容量。较早的页面省略会话级投影。实时 `session/metrics` mux 帧携带单调递增的日志修订号与投影修订号,因此客户端会拒绝陈旧帧,并在向前加载较早页面时保留计数器。缓存读取与缓存写入保持为彼此独立的计数项;缓存命中率的分母是未缓存输入加缓存读取。 +`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)携带页窗口本身无法提供的会话级投影:进行中局部消息的分片事件;`todos`,即最后一次 `todo/write` 的整表投影;以及 `metrics`,即按 `(turn, step)` 去重的完整日志用量与当前 token 计量压力。较早的页面省略会话级投影。实时 `session/metrics` mux 帧携带单调递增的日志修订号与投影修订号,因此客户端会拒绝陈旧帧,并在向前加载较早页面时保留计数器。缓存读取与缓存写入保持为彼此独立的计数项;缓存命中率的分母是未缓存输入加缓存读取。 + +上下文容量使用独立的临时 `session/model-request` mux 帧;实际请求到达分派点后,该帧由失败会被收容的 Agent 通知发出。该帧携带轮次、步骤、最终提供方/模型与可选容量,且只发送给当时已经打开的 mux 连接。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放先前的容量;不带容量的帧会显式清除较早的连接本地值。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 440c3a1694..a9a753db66 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -6,7 +6,6 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import { join } from 'node:path' -import { FiberState } from 'cordis' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { @@ -409,26 +408,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return target } - /** - * Read the best capacity route without taking ownership of foreign routing. - * Web agents expose their live selection; other agents expose only a route - * that already crossed the durable request-header boundary. - */ - function metricsRouteFor(agent: Agent): Pick | undefined { - const installed = targets.get(agent) - if (installed !== undefined) return installed.current - const logged = agent.session.requestHeader()?.config - return logged === undefined - ? undefined - : { provider: logged.provider, model: logged.model } - } - - /** Pair a registry agent only with the exact Session lifecycle it owns. */ - function metricsAgentFor(session: Session): Agent | undefined { - const agent = ctx.get('agents')?.get(session.id) - return agent?.session === session ? agent : undefined - } - /** Pre-publication setup used by both fresh and resumed Web agents. */ function installTarget(agentCtx: Context): void { const agent = agentCtx.agent @@ -444,39 +423,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const pendingMetricSessions = new Set() let metricFlushScheduled = false - let metricsDisposed = false - const metricsProjector = new SessionMetricsProjector( - ctx, - metricsRouteFor, - (agent) => { - if (metricsDisposed) return - const agents = ctx.get('agents') - if (agents?.get(agent.id) !== agent) return - const sessions = ctx.get('sessions') - if (sessions?.get(agent.id) !== agent.session) return - scheduleMetrics(agent.session) - }, - ) + const metricsProjector = new SessionMetricsProjector(ctx) /** Queue one full-log metrics publication after synchronous session listeners drain. */ function scheduleMetrics(session: Session): void { - if (metricsDisposed || muxQueues.size === 0) return + if (muxQueues.size === 0) return pendingMetricSessions.add(session) if (metricFlushScheduled) return metricFlushScheduled = true queueMicrotask(() => { metricFlushScheduled = false - if (metricsDisposed) { - pendingMetricSessions.clear() - return - } const sessions = [...pendingMetricSessions] pendingMetricSessions.clear() for (const current of sessions) { broadcast({ type: 'session/metrics', sessionId: current.id, - metrics: metricsProjector.snapshot(current, metricsAgentFor(current)), + metrics: metricsProjector.snapshot(current), }) } }) @@ -488,25 +451,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (affectsSessionMetrics(event)) scheduleMetrics(session) }), ctx.on('agent/created', (agent: Agent) => { scheduleMetrics(agent.session) }), + ctx.on('agent/model-request', (agent, turn, step, request) => { + broadcast({ + type: 'session/model-request', + sessionId: agent.session.id, + turn, + step, + provider: request.provider, + model: request.model, + ...request.contextWindow === undefined + ? {} + : { contextWindow: request.contextWindow }, + }) + }), ctx.on('session/disposed', (session: Session) => { pendingMetricSessions.delete(session) }), - ctx.on('internal/status', (fiber) => { - if (metricsDisposed) return - if (fiber.state === FiberState.UNLOADING) { - metricsProjector.invalidateCapacities() - return - } - if (fiber.state !== FiberState.ACTIVE - && fiber.state !== FiberState.FAILED - && fiber.state !== FiberState.DISPOSED) return - metricsProjector.invalidateCapacities() - const sessions = ctx.get('sessions') - if (sessions === undefined) return - for (const session of sessions.list()) scheduleMetrics(session) - }, { global: true }), ] return () => { - metricsDisposed = true - metricsProjector.dispose() pendingMetricSessions.clear() for (const dispose of disposers) dispose() } @@ -819,7 +779,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // client cannot reconstruct session-level state from it). const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined const metrics = beforeSeq === undefined - ? metricsProjector.snapshot(found.agent.session, found.agent) + ? metricsProjector.snapshot(found.agent.session) : undefined return ok(request, { events: entries, @@ -920,11 +880,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro : { reasoningEffort: resolved.reasoningEffort }, } targetFor(found.agent).current = selected - broadcast({ - type: 'session/metrics', - sessionId: found.agent.session.id, - metrics: metricsProjector.snapshot(found.agent.session, found.agent), - }) return ok(request, { selected: { ...selected } }) } catch (error: unknown) { return err(request, { @@ -1235,7 +1190,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'session/metrics', sessionId: session.id, - metrics: metricsProjector.snapshot(session, metricsAgentFor(session)), + metrics: metricsProjector.snapshot(session), })) } for (const pending of pendingQuestions.values()) { @@ -1292,7 +1247,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'session/metrics', sessionId: session.id, - metrics: metricsProjector.snapshot(session, metricsAgentFor(session)), + metrics: metricsProjector.snapshot(session), })) }), ctx.on('session/disposed', (session: Session) => { diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 0f317128dd..b69392165c 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -30,6 +30,15 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }), z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }), z.object({ type: z.literal('session/metrics'), sessionId: sessionIdSchema, metrics: sessionMetricsSchema }), + z.object({ + type: z.literal('session/model-request'), + sessionId: sessionIdSchema, + turn: z.number().int().positive(), + step: z.number().int().positive(), + provider: z.string().min(1), + model: z.string().min(1), + contextWindow: z.number().int().positive().optional(), + }), z.object({ type: z.literal('session/title'), sessionId: sessionIdSchema, title: z.string().min(1), eventSeq: z.number().int().nonnegative(), updatedAt: z.number() }), z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }), z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 73e2b42deb..c4babb6017 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -59,6 +59,22 @@ export type MuxFrame = | { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView } | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number } | { type: 'session/metrics'; sessionId: SessionId; metrics: SessionMetrics } + /** + * One model request observed by this already-open mux connection after its + * final route and stream handle were resolved. This frame is transient: mux + * baselines, reconnects, and session history never replay it. An absent + * `contextWindow` explicitly clears a capacity observed from an earlier + * request on the same connection. + */ + | { + type: 'session/model-request' + sessionId: SessionId + turn: number + step: number + provider: string + model: string + contextWindow?: number + } | { type: 'session/title'; sessionId: SessionId; title: string; eventSeq: number; updatedAt: number } | { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string } | { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 0866766da5..544562f2e4 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -145,7 +145,7 @@ export const todoItemSchema = z.object({ status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]), }) -/** Host-owned durable usage and current-context projection. */ +/** Host-owned durable usage and current-pressure projection. */ export const sessionMetricsSchema = z.object({ logRevision: z.number().int().nonnegative(), projectionRevision: z.number().int().nonnegative(), @@ -154,7 +154,6 @@ export const sessionMetricsSchema = z.object({ cacheReadTokens: z.number().nonnegative(), cacheWriteTokens: z.number().nonnegative(), contextTokens: z.number().nonnegative().optional(), - contextWindow: z.number().int().positive().optional(), }) satisfies z.ZodType> /** session.history response value. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 9a0344867e..c8c10757fd 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -34,9 +34,9 @@ export interface HistoryEntry { /** * Host-owned token metrics for one durable session revision. Provider usage - * buckets are cumulative across the full log; current context fields describe - * the replayed request surface at this revision and are absent when the Host - * cannot measure pressure or resolve exact-route capacity. + * buckets are cumulative across the full log; current context pressure + * describes the replayed request surface at this revision and is absent when + * the Host cannot measure it. */ export interface SessionMetrics { /** Number of durable events included in this projection. */ @@ -53,8 +53,6 @@ export interface SessionMetrics { cacheWriteTokens: number /** Current request pressure from `ctx.tokenMeter.measure(session).totalTokens`. */ contextTokens?: number - /** Exact selected-route capacity from `ctx.llm.resolveModelInfo()`. */ - contextWindow?: number } /** Complete model target selected for one session. */ @@ -178,8 +176,10 @@ export interface SessionsApi { * projection (latest `todo/write` over the FULL log, independent of the page window) — * so a paged client restores the plan without walking history; absent when the session * never wrote one. Older pages omit it (the projection is session-level, not per-page). - * The same tail-only rule carries `metrics`, whose cumulative usage and current context - * are Host projections over the full log rather than products of the returned page. + * The same tail-only rule carries `metrics`, whose cumulative usage and + * current pressure are Host projections over the full log rather than + * products of the returned page. Live model capacity is connection-local + * telemetry and is never reconstructed here. */ history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): Promise> diff --git a/packages/host/apiproxy/src/session-metrics.ts b/packages/host/apiproxy/src/session-metrics.ts index 1eebc7b7d8..a99535f6bc 100644 --- a/packages/host/apiproxy/src/session-metrics.ts +++ b/packages/host/apiproxy/src/session-metrics.ts @@ -5,7 +5,6 @@ */ import type { Context } from 'cordis' -import type { Agent, AgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { TokenUsage } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionMetrics } from './api/sessions.ts' @@ -20,27 +19,10 @@ interface UsageState { byStep: Map } -interface CapacityState { - routeKey: string | undefined - generation: number - epoch: number - status: 'pending' | 'ready' | 'retryable' - contextWindow?: number - controller?: AbortController -} - -type CapacityTarget = Pick - interface TokenMeterLike { measure(session: Session): { totalTokens: number } } -interface LlmLike { - resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{ - context?: { contextWindow: number } - }> -} - function usageFrom(event: SessionEvent): { turn: number; step: number; usage: TokenUsage } | undefined { if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage } @@ -79,51 +61,19 @@ function recordUsage(state: UsageState, turn: number, step: number, usage: Token state.cacheWriteTokens += usage.cacheWriteTokens ?? 0 } -function routeKeyFor(target: CapacityTarget | undefined): string | undefined { - return target === undefined ? undefined : `${target.provider}\u0000${target.model}` -} - -/** - * Projects durable cumulative usage and route-aware current context without - * awaiting model metadata on the session append path. - */ +/** Projects durable cumulative usage and synchronous current context pressure. */ export class SessionMetricsProjector { private readonly usage = new WeakMap() - private readonly capacities = new WeakMap() - private readonly pendingCapacities = new Set() - private capacityEpoch = 0 - private disposed = false - /** - * @param ctx - Host context providing optional token-meter and LLM services. - * @param targetFor - side-effect-free selected or logged route lookup for one attached agent. - * @param onCapacityResolved - schedules a fresh live projection after exact-route metadata resolves. - */ - constructor( - private readonly ctx: Context, - private readonly targetFor: (agent: Agent) => CapacityTarget | undefined, - private readonly onCapacityResolved: (agent: Agent) => void, - ) {} - - /** Retire adapter-owned metadata and fence every resolution already in flight. */ - invalidateCapacities(): void { - this.capacityEpoch++ - for (const pending of this.pendingCapacities) this.abortCapacityResolution(pending) - } - - /** Permanently retire capacity projection and cancel every adapter-owned lookup. */ - dispose(): void { - this.disposed = true - this.invalidateCapacities() - } + /** @param ctx - Host context providing an optional token-meter service. */ + constructor(private readonly ctx: Context) {} /** * Read a fresh detached projection through the session's durable tail. * @param session - authoritative durable log owner. - * @param agent - attached route owner, when available. - * @returns cumulative usage and any currently available pressure/capacity. + * @returns cumulative usage and any currently measurable pressure. */ - snapshot(session: Session, agent?: Agent): SessionMetrics { + snapshot(session: Session): SessionMetrics { const state = this.syncUsage(session) const tokenMeter = this.ctx.get('tokenMeter') as TokenMeterLike | undefined let contextTokens: number | undefined @@ -134,7 +84,6 @@ export class SessionMetricsProjector { // A malformed or temporarily unmeasurable replay has no honest pressure value. } } - const contextWindow = agent === undefined ? undefined : this.capacityFor(agent) return { logRevision: state.logRevision, projectionRevision: state.projectionRevision++, @@ -143,7 +92,6 @@ export class SessionMetricsProjector { cacheReadTokens: state.cacheReadTokens, cacheWriteTokens: state.cacheWriteTokens, ...contextTokens === undefined ? {} : { contextTokens }, - ...contextWindow === undefined ? {} : { contextWindow }, } } @@ -171,87 +119,4 @@ export class SessionMetricsProjector { } return state } - - private capacityFor(agent: Agent): number | undefined { - if (this.disposed) return undefined - const target = this.targetFor(agent) - const routeKey = routeKeyFor(target) - let state = this.capacities.get(agent) - if (state === undefined - || state.routeKey !== routeKey - || state.epoch !== this.capacityEpoch - || state.status === 'retryable') { - this.abortCapacityResolution(state) - state = { - routeKey, - generation: (state?.generation ?? 0) + 1, - epoch: this.capacityEpoch, - status: target === undefined ? 'ready' : 'pending', - } - this.capacities.set(agent, state) - if (target !== undefined) this.resolveCapacity(agent, target, state) - } - return state.status === 'ready' ? state.contextWindow : undefined - } - - private resolveCapacity( - agent: Agent, - target: CapacityTarget, - pending: CapacityState, - ): void { - const llm = this.ctx.get('llm') as LlmLike | undefined - if (llm === undefined) { - pending.status = 'retryable' - return - } - const controller = new AbortController() - pending.controller = controller - this.pendingCapacities.add(pending) - void Promise.resolve() - .then(() => { - controller.signal.throwIfAborted() - return llm.resolveModelInfo(target.provider, target.model, controller.signal) - }) - .then( - (resolved) => { - this.finishCapacityResolution(pending, controller) - if (this.capacityResolutionIsStale(agent, pending)) return - pending.status = 'ready' - if (resolved.context !== undefined) pending.contextWindow = resolved.context.contextWindow - this.onCapacityResolved(agent) - }, - () => { - this.finishCapacityResolution(pending, controller) - if (!this.capacityResolutionIsStale(agent, pending)) pending.status = 'retryable' - }, - ) - } - - private abortCapacityResolution(pending: CapacityState | undefined): void { - if (pending === undefined || pending.controller === undefined) return - const controller = pending.controller - delete pending.controller - this.pendingCapacities.delete(pending) - controller.abort() - } - - private finishCapacityResolution(pending: CapacityState, controller: AbortController): void { - this.pendingCapacities.delete(pending) - if (pending.controller === controller) delete pending.controller - } - - private capacityResolutionIsStale(agent: Agent, pending: CapacityState): boolean { - if (pending.epoch !== this.capacityEpoch) return true - if (this.capacities.get(agent)?.generation !== pending.generation) return true - if (routeKeyFor(this.targetFor(agent)) === pending.routeKey) return false - // Unknown is the neutral generation; the next observed concrete route - // starts a fresh resolution even when it equals the route that disappeared. - this.capacities.set(agent, { - routeKey: undefined, - generation: pending.generation + 1, - epoch: this.capacityEpoch, - status: 'ready', - }) - return true - } } diff --git a/packages/host/apiproxy/tests/api-proxy-model-request.spec.ts b/packages/host/apiproxy/tests/api-proxy-model-request.spec.ts new file mode 100644 index 0000000000..2194afe131 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-model-request.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' +import { createApiProxy } from '../src/api-proxy.ts' + +async function nextFrame( + iterator: AsyncIterator>, + type: K, +): Promise> { + for (;;) { + const next = await iterator.next() + if (next.done) throw new Error(`mux ended before ${type}`) + if (next.value.payload.type === type) { + return next.value.payload as Extract + } + } +} + +describe('ApiProxy model-request telemetry', () => { + it('forwards only to open mux connections and never backfills history or reconnect baselines', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(SessionId('model-request-telemetry')) + const agent = { + id: session.id, + session, + status: 'running', + ctx, + } as Agent + ctx.agents.register(agent) + const api = createApiProxy(ctx, { + provider: 'test', + model: 'alpha', + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + const primaryAbort = new AbortController() + const primary = api.events.mux( + { rpcId: RpcId('primary'), payload: {} }, + primaryAbort.signal, + )[Symbol.asyncIterator]() + expect((await nextFrame(primary, 'session/subscribed')).sessionId).toBe(session.id) + expect((await nextFrame(primary, 'session/metrics')).metrics).not.toHaveProperty('contextWindow') + + agentEvents(ctx, agent).emit('agent/model-request', 1, 2, { + provider: 'test', + model: 'alpha', + contextWindow: 128_000, + }) + expect(await nextFrame(primary, 'session/model-request')).toEqual({ + type: 'session/model-request', + sessionId: session.id, + turn: 1, + step: 2, + provider: 'test', + model: 'alpha', + contextWindow: 128_000, + }) + + const history = await api.sessions.history({ + rpcId: RpcId('history'), + payload: { sessionId: session.id }, + }) + if (!history.result.ok) throw new Error('history failed') + expect(history.result.value.metrics).not.toHaveProperty('contextWindow') + + const reconnectAbort = new AbortController() + const reconnect = api.events.mux( + { rpcId: RpcId('reconnect'), payload: {} }, + reconnectAbort.signal, + )[Symbol.asyncIterator]() + expect((await nextFrame(reconnect, 'session/subscribed')).sessionId).toBe(session.id) + expect((await nextFrame(reconnect, 'session/metrics')).metrics).not.toHaveProperty('contextWindow') + + agentEvents(ctx, agent).emit('agent/model-request', 2, 1, { + provider: 'test', + model: 'without-capacity', + }) + for (const iterator of [primary, reconnect]) { + expect(await nextFrame(iterator, 'session/model-request')).toEqual({ + type: 'session/model-request', + sessionId: session.id, + turn: 2, + step: 1, + provider: 'test', + model: 'without-capacity', + }) + } + + primaryAbort.abort() + reconnectAbort.abort() + await primary.return?.() + await reconnect.return?.() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 7b2595e528..9d3e6ef34c 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -4,23 +4,20 @@ * models, and the prompt-assembly boundary for a running selection change. */ -import { describe, expect, it, vi } from 'vitest' -import { Context, FiberState } from 'cordis' -import type { Fiber } from 'cordis' -import AgentRegistry, { agentEvents, installAgentLlmTarget } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk, } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import { createApiProxy } from '../src/api-proxy.ts' let nextRpc = 1 @@ -64,40 +61,6 @@ class CatalogAdapter extends LlmAdapter { } } -class DeferredCatalogAdapter extends CatalogAdapter { - readonly pending: { - result: PromiseWithResolvers - signal: AbortSignal | undefined - }[] = [] - - constructor() { - super('Deferred', [ - { provider: 'deferred', id: 'lifecycle-model', name: 'Lifecycle model' }, - ]) - } - - override resolveModel( - _provider: string, - _model: string, - signal?: AbortSignal, - ): Promise { - const result = Promise.withResolvers() - this.pending.push({ result, signal }) - return result.promise - } - - resolve(index: number, contextWindow: number): void { - const pending = this.pending[index] - if (pending === undefined) throw new Error(`no pending resolution at index ${String(index)}`) - pending.result.resolve({ - provider: 'deferred', - id: 'lifecycle-model', - name: 'Lifecycle model', - context: { contextWindow }, - }) - } -} - const REASONING: LlmModelReasoningInfo = { efforts: [ { id: ReasoningEffortId('off'), name: 'Off' }, @@ -107,18 +70,13 @@ const REASONING: LlmModelReasoningInfo = { defaultEffort: ReasoningEffortId('high'), } -async function hostContext( - onSessions?: (fiber: Fiber) => void, - onAgents?: (fiber: Fiber) => void, -): Promise { +async function hostContext(): Promise { const ctx = new Context() - const sessionsFiber = await ctx.plugin(SessionStore) - onSessions?.(sessionsFiber) + await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(LlmService) await ctx.plugin(UserInteractionService) - const agentsFiber = await ctx.plugin(AgentRegistry) - onAgents?.(agentsFiber) + await ctx.plugin(AgentRegistry) ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [ { provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' }, { provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' }, @@ -164,65 +122,6 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false return response.result.value } -async function nextMetrics( - iterator: AsyncIterator>, -): Promise['metrics']> { - for (;;) { - const next = await iterator.next() - if (next.done) throw new Error('mux ended before a metrics frame') - if (next.value.payload.type === 'session/metrics') return next.value.payload.metrics - } -} - -function attachLifecycleSession( - ctx: Context, - sessionId: SessionId, - withMarker = false, -): { session: Session; detach: () => void } { - const session = ctx.sessions.prepare(sessionId) - session.append('request/header', { - header: { config: { provider: 'deferred', model: 'lifecycle-model' } }, - reason: 'initial', - }) - if (withMarker) { - session.append('user/message', { - content: [{ type: 'text', text: 'replacement marker' }], - source: { kind: 'plugin', plugin: 'test' }, - }, { surfaceOp: 'append' }) - } - const detach = ctx.sessions.enter(session) - ctx.sessions.announce(session) - return { session, detach } -} - -function attachLifecycleAgent( - ctx: Context, - session: Session, -): () => void { - const agent = { - id: session.id, - session, - status: 'running', - ctx, - } as Agent - const detach = ctx.agents.enter(agent, undefined) - ctx.agents.announce(agent) - return detach -} - -function settleCapacityCompletion(): Promise { - return new Promise((resolve) => { setImmediate(resolve) }) -} - -function installDeferredAdapter( - ctx: Context, - adapter: DeferredCatalogAdapter, -): Fiber & PromiseLike { - return ctx.plugin(Object.assign((inner: Context) => { - inner.llm.registerAdapter(['deferred'], adapter) - }, { inject: ['llm'] })) -} - describe('Web session model selection', () => { it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => { const { ctx, sessionId } = await harness({ @@ -230,7 +129,12 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { + provider: 'deepseek', + model: 'deepseek-chat', + cwd: '/tmp', + workspaceRoot: '/tmp', + }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) expect(catalog.current).toEqual({ @@ -271,7 +175,12 @@ describe('Web session model selection', () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { + provider: 'deepseek', + model: 'deepseek-chat', + cwd: '/tmp', + workspaceRoot: '/tmp', + }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal @@ -336,534 +245,4 @@ describe('Web session model selection', () => { .toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' }) await ctx.fiber.dispose() }) - - it('publishes unknown capacity immediately on selection, then the exact selected route capacity', async () => { - const { ctx, sessionId } = await harness() - const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - expectValue(await api.sessions.models(request({ sessionId }))) - const controller = new AbortController() - const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() - - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - expect((await nextMetrics(iterator)).contextWindow).toBe(64_000) - - expectValue(await api.sessions.selectModel(request({ - sessionId, - provider: 'deepseek', - model: 'private-preview', - }))) - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) - - controller.abort() - await iterator.return?.() - await ctx.fiber.dispose() - }) - - it('uses logged capacity without installing Web routing while scheduling foreign metrics', async () => { - const ctx = await hostContext() - const api = createApiProxy(ctx, { - provider: 'deepseek', - model: 'deepseek-chat', - cwd: '/tmp', - workspaceRoot: '/tmp', - }) - const controller = new AbortController() - const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() - const initialMetrics = nextMetrics(iterator) - const session = ctx.sessions.create() - expect((await initialMetrics).contextWindow).toBeUndefined() - session.append('request/header', { - header: { config: { provider: 'deepseek', model: 'private-preview' } }, - reason: 'change', - }) - const foreign = { - id: session.id, - session, - status: 'running', - ctx, - } as Agent - const foreignTarget: AgentLlmTargetRef = { - current: { provider: 'foreign', model: 'foreign-model' }, - assembled: undefined, - } - const disposeForeignTarget = installAgentLlmTarget(foreign.ctx, foreignTarget) - const scheduledMetrics = nextMetrics(iterator) - ctx.agents.register(foreign) - - expect((await scheduledMetrics).contextWindow).toBeUndefined() - expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) - expect((await ctx.systemPrompt.assemble()).variables) - .toMatchObject({ provider: 'foreign', model: 'foreign-model' }) - const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } - const signal = new AbortController().signal - await expect(agentEvents(ctx, foreign).waterfall( - 'agent/request', 1, 0, signal, () => Promise.resolve(seed), - )).resolves.toMatchObject({ provider: 'foreign', model: 'foreign-model' }) - - disposeForeignTarget() - expect((await ctx.systemPrompt.assemble()).variables).not.toHaveProperty('provider') - await expect(agentEvents(ctx, foreign).waterfall( - 'agent/request', 1, 1, signal, () => Promise.resolve(seed), - )).resolves.toBe(seed) - - controller.abort() - await iterator.return?.() - await ctx.fiber.dispose() - }) - - it('drops capacity completion from a replaced agent that retains the exact session', async () => { - const ctx = await hostContext() - const deferred = new DeferredCatalogAdapter() - ctx.llm.registerAdapter(['deferred'], deferred) - const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-agent-lifecycle')) - const retire = attachLifecycleAgent(ctx, lifecycle.session) - const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - const controller = new AbortController() - const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() - - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) - retire() - const detachLive = attachLifecycleAgent(ctx, lifecycle.session) - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) }) - - deferred.resolve(0, 64_000) - await settleCapacityCompletion() - deferred.resolve(1, 128_000) - await settleCapacityCompletion() - expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) - - controller.abort() - await iterator.return?.() - detachLive() - lifecycle.detach() - await ctx.fiber.dispose() - }) - - it('drops capacity completion from a replaced session while its old agent remains live', async () => { - const ctx = await hostContext() - const deferred = new DeferredCatalogAdapter() - ctx.llm.registerAdapter(['deferred'], deferred) - const sessionId = SessionId('capacity-session-lifecycle') - const retiredSession = attachLifecycleSession(ctx, sessionId) - const retireAgent = attachLifecycleAgent(ctx, retiredSession.session) - const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - const controller = new AbortController() - const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() - - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) - retiredSession.detach() - const liveSession = attachLifecycleSession(ctx, sessionId, true) - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - deferred.resolve(0, 64_000) - await settleCapacityCompletion() - retireAgent() - const detachLiveAgent = attachLifecycleAgent(ctx, liveSession.session) - const scheduled = await nextMetrics(iterator) - expect(scheduled.logRevision).toBe(2) - expect(scheduled.contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) }) - deferred.resolve(1, 128_000) - await settleCapacityCompletion() - expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) - - controller.abort() - await iterator.return?.() - detachLiveAgent() - liveSession.detach() - await ctx.fiber.dispose() - }) - - it('does not project retired agent capacity into replacement session snapshots', async () => { - const ctx = await hostContext() - const deferred = new DeferredCatalogAdapter() - ctx.llm.registerAdapter(['deferred'], deferred) - const sessionId = SessionId('capacity-snapshot-lifecycle') - const retiredSession = attachLifecycleSession(ctx, sessionId) - const retireAgent = attachLifecycleAgent(ctx, retiredSession.session) - const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - const primaryController = new AbortController() - const primary = api.events.mux(request({}), primaryController.signal)[Symbol.asyncIterator]() - - expect((await nextMetrics(primary)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) - deferred.resolve(0, 64_000) - await settleCapacityCompletion() - expect((await nextMetrics(primary)).contextWindow).toBe(64_000) - - retiredSession.detach() - const replacement = attachLifecycleSession(ctx, sessionId) - const createdBaseline = await nextMetrics(primary) - replacement.session.append('user/message', { - content: [{ type: 'text', text: 'replacement marker' }], - source: { kind: 'plugin', plugin: 'test' }, - }, { surfaceOp: 'append' }) - const scheduledFlush = await nextMetrics(primary) - const reconnectController = new AbortController() - const reconnect = api.events.mux(request({}), reconnectController.signal)[Symbol.asyncIterator]() - const reconnectBaseline = await nextMetrics(reconnect) - expect(createdBaseline.logRevision).toBe(1) - for (const metrics of [scheduledFlush, reconnectBaseline]) { - expect(metrics.logRevision).toBe(2) - } - expect({ - created: createdBaseline.contextWindow, - scheduled: scheduledFlush.contextWindow, - reconnect: reconnectBaseline.contextWindow, - }).toEqual({ created: undefined, scheduled: undefined, reconnect: undefined }) - - retireAgent() - const detachReplacementAgent = attachLifecycleAgent(ctx, replacement.session) - expect((await nextMetrics(primary)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) }) - deferred.resolve(1, 128_000) - await settleCapacityCompletion() - expect((await nextMetrics(primary)).contextWindow).toBe(128_000) - - primaryController.abort() - reconnectController.abort() - await primary.return?.() - await reconnect.return?.() - detachReplacementAgent() - replacement.detach() - await ctx.fiber.dispose() - }) - - it('refreshes same-route capacity after adapter owner replacement', async () => { - const ctx = await hostContext() - const retiredAdapter = new DeferredCatalogAdapter() - const retiredFiber = await installDeferredAdapter(ctx, retiredAdapter) - const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-lifecycle')) - const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) - const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - const controller = new AbortController() - const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() - - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) }) - retiredAdapter.resolve(0, 64_000) - await settleCapacityCompletion() - expect((await nextMetrics(iterator)).contextWindow).toBe(64_000) - - await retiredFiber.dispose() - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - const replacementAdapter = new DeferredCatalogAdapter() - const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter) - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) }) - replacementAdapter.resolve(0, 128_000) - await settleCapacityCompletion() - expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) - expect(lifecycle.session.requestHeader()?.config).toMatchObject({ - provider: 'deferred', - model: 'lifecycle-model', - }) - - controller.abort() - await iterator.return?.() - detachAgent() - lifecycle.detach() - await replacementFiber.dispose() - await ctx.fiber.dispose() - }) - - it('aborts pending capacity during adapter UNLOADING and refreshes after settlement', async () => { - const ctx = await hostContext() - const retiredAdapter = new DeferredCatalogAdapter() - const releaseUnload = Promise.withResolvers() - const releaseCancellationWait = Promise.withResolvers() - const abortObserved = Promise.withResolvers() - const retiredFiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.llm.registerAdapter(['deferred'], retiredAdapter) - inner.effect( - () => () => releaseUnload.promise, - 'test: hold adapter unload', - ) - inner.effect(() => () => { - const signal = retiredAdapter.pending[0]?.signal - if (signal === undefined) throw new Error('pending capacity signal missing') - const cancellation = new Promise((resolve) => { - const finish = () => { - abortObserved.resolve(undefined) - resolve() - } - if (signal.aborted) finish() - else signal.addEventListener('abort', finish, { once: true }) - }) - return Promise.race([cancellation, releaseCancellationWait.promise]) - }, 'test: await capacity cancellation') - }, { inject: ['llm'] })) - const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-unloading')) - const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) - const api = createApiProxy(ctx, { - provider: 'deepseek', - model: 'deepseek-chat', - cwd: '/tmp', - workspaceRoot: '/tmp', - }) - const controller = new AbortController() - const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() - const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo') - - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) }) - expect(resolveModelInfo).toHaveBeenCalledOnce() - const listSessions = vi.spyOn(ctx.sessions, 'list') - listSessions.mockClear() - const pendingFrame = iterator.next() - const disposing = retiredFiber.dispose() - try { - await vi.waitFor(() => { - expect(retiredAdapter.pending[0]?.signal?.aborted).toBe(true) - }) - await abortObserved.promise - expect(retiredFiber.state).toBe(FiberState.UNLOADING) - retiredAdapter.resolve(0, 64_000) - await settleCapacityCompletion() - expect(resolveModelInfo).toHaveBeenCalledOnce() - expect(listSessions).not.toHaveBeenCalled() - const outcome = await Promise.race([ - pendingFrame.then(() => 'frame' as const), - new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }), - ]) - expect(outcome).toBe('idle') - } finally { - releaseCancellationWait.resolve(undefined) - releaseUnload.resolve(undefined) - await disposing - } - - expect(retiredFiber.state).toBe(FiberState.DISPOSED) - const settledFrame = await pendingFrame - if (settledFrame.done || settledFrame.value.payload.type !== 'session/metrics') { - throw new Error('expected settled metrics refresh') - } - expect(settledFrame.value.payload.metrics.contextWindow).toBeUndefined() - await settleCapacityCompletion() - expect(resolveModelInfo).toHaveBeenCalledTimes(2) - - const replacementAdapter = new DeferredCatalogAdapter() - const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter) - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) }) - expect(resolveModelInfo).toHaveBeenCalledTimes(3) - replacementAdapter.resolve(0, 128_000) - await settleCapacityCompletion() - expect((await nextMetrics(iterator)).contextWindow).toBe(128_000) - - controller.abort() - await iterator.return?.() - detachAgent() - lifecycle.detach() - await replacementFiber.dispose() - await ctx.fiber.dispose() - }) - - it('aborts pending capacity when the API proxy fiber is disposed', async () => { - const ctx = await hostContext() - const deferred = new DeferredCatalogAdapter() - ctx.llm.registerAdapter(['deferred'], deferred) - const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-api-proxy-teardown')) - const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) - const proxy = Promise.withResolvers>() - const proxyFiber = await ctx.plugin(Object.assign((inner: Context) => { - proxy.resolve(createApiProxy(inner, { - provider: 'deepseek', - model: 'deepseek-chat', - cwd: '/tmp', - workspaceRoot: '/tmp', - })) - }, { inject: ['agents', 'sessions', 'userInteraction'] })) - const api = await proxy.promise - const controller = new AbortController() - const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() - - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) - expect(deferred.pending[0]?.signal?.aborted).toBe(false) - - await proxyFiber.dispose() - expect(deferred.pending[0]?.signal?.aborted).toBe(true) - deferred.resolve(0, 64_000) - await settleCapacityCompletion() - const pendingFrame = iterator.next() - const outcome = await Promise.race([ - pendingFrame.then(() => 'frame' as const), - new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }), - ]) - expect(outcome).toBe('idle') - - controller.abort() - await expect(pendingFrame).resolves.toMatchObject({ done: true }) - await iterator.return?.() - detachAgent() - lifecycle.detach() - await ctx.fiber.dispose() - }) - - it('does not read the sessions service after its disposal status', async () => { - let sessionsFiber: Fiber | undefined - const ctx = await hostContext((fiber) => { sessionsFiber = fiber }) - if (sessionsFiber === undefined) throw new Error('sessions fiber missing') - createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - const sessions = ctx.get('sessions') - if (sessions === undefined) throw new Error('sessions service missing') - const list = vi.spyOn(sessions, 'list').mockImplementation(() => { - throw new Error('disposed sessions service read') - }) - - await expect(sessionsFiber.dispose()).resolves.toBeUndefined() - expect(list).not.toHaveBeenCalled() - await ctx.fiber.dispose() - }) - - it('invalidates pending capacity before SessionStore teardown can reach its callback', async () => { - let sessionsFiber: Fiber | undefined - const ctx = await hostContext((fiber) => { sessionsFiber = fiber }) - if (sessionsFiber === undefined) throw new Error('sessions fiber missing') - const deferred = new DeferredCatalogAdapter() - ctx.llm.registerAdapter(['deferred'], deferred) - const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-session-store-teardown')) - const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) - const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - const controller = new AbortController() - const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() - - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) - const agents = ctx.get('agents') - if (agents === undefined) throw new Error('agent registry missing') - expect(agents.get(lifecycle.session.id)).toBeDefined() - const getAgent = vi.spyOn(agents, 'get') - - await sessionsFiber.dispose() - getAgent.mockClear() - deferred.resolve(0, 64_000) - await settleCapacityCompletion() - expect(getAgent).not.toHaveBeenCalled() - - const pendingFrame = iterator.next() - const outcome = await Promise.race([ - pendingFrame.then(() => 'frame' as const), - new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }), - ]) - expect(outcome).toBe('idle') - - controller.abort() - await expect(pendingFrame).resolves.toMatchObject({ done: true }) - await iterator.return?.() - detachAgent() - lifecycle.detach() - await ctx.fiber.dispose() - }) - - it('publishes unknown metrics after AgentRegistry terminal disposal with mux active', async () => { - let agentsFiber: Fiber | undefined - const ctx = await hostContext(undefined, (fiber) => { agentsFiber = fiber }) - if (agentsFiber === undefined) throw new Error('agent registry fiber missing') - const deferred = new DeferredCatalogAdapter() - ctx.llm.registerAdapter(['deferred'], deferred) - const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-agent-registry-disposed')) - const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) - const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - const controller = new AbortController() - const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() - - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) - deferred.resolve(0, 64_000) - await settleCapacityCompletion() - expect((await nextMetrics(iterator)).contextWindow).toBe(64_000) - - await agentsFiber.dispose() - const refresh = nextMetrics(iterator).then( - metrics => ({ kind: 'metrics' as const, metrics }), - () => ({ kind: 'error' as const }), - ) - const outcome = await Promise.race([ - refresh, - new Promise<{ kind: 'idle' }>((resolve) => { - setImmediate(() => { resolve({ kind: 'idle' }) }) - }), - ]) - - controller.abort() - await refresh - await iterator.return?.() - detachAgent() - lifecycle.detach() - await ctx.fiber.dispose() - expect(outcome.kind).toBe('metrics') - if (outcome.kind === 'metrics') { - expect(outcome.metrics.contextWindow).toBeUndefined() - expect(outcome.metrics.logRevision).toBe(1) - } - }) - - it.each(['agents', 'sessions'] as const)( - 'drops capacity completion while %s is unavailable during unload', - async (serviceName) => { - let sessionsFiber: Fiber | undefined - let agentsFiber: Fiber | undefined - const ctx = await hostContext( - (fiber) => { sessionsFiber = fiber }, - (fiber) => { agentsFiber = fiber }, - ) - const heldFiber = serviceName === 'sessions' ? sessionsFiber : agentsFiber - if (heldFiber === undefined) throw new Error(`${serviceName} fiber missing`) - const unloadStarted = Promise.withResolvers() - const releaseUnload = Promise.withResolvers() - heldFiber.ctx.effect(() => () => { - unloadStarted.resolve(undefined) - return releaseUnload.promise - }, `test: hold ${serviceName} unload`) - const deferred = new DeferredCatalogAdapter() - ctx.llm.registerAdapter(['deferred'], deferred) - const lifecycle = attachLifecycleSession(ctx, SessionId(`capacity-${serviceName}-unloading`)) - const detachAgent = attachLifecycleAgent(ctx, lifecycle.session) - const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' }) - const controller = new AbortController() - const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]() - - expect((await nextMetrics(iterator)).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) }) - const agents = ctx.get('agents') - if (agents === undefined) throw new Error('agent registry missing') - const sessions = ctx.get('sessions') - if (sessions === undefined) throw new Error('sessions service missing') - const getAgent = vi.spyOn(agents, 'get') - const getSession = vi.spyOn(sessions, 'get') - const disposing = heldFiber.dispose() - await unloadStarted.promise - await vi.waitFor(() => { expect(ctx.get(serviceName)).toBeUndefined() }) - expect(heldFiber.state).toBe(FiberState.UNLOADING) - - getAgent.mockClear() - getSession.mockClear() - deferred.resolve(0, 64_000) - await settleCapacityCompletion() - const agentReads = getAgent.mock.calls.length - const sessionReads = getSession.mock.calls.length - const pendingFrame = iterator.next() - const outcome = await Promise.race([ - pendingFrame.then(() => 'frame' as const), - new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }), - ]) - - controller.abort() - await expect(pendingFrame).resolves.toMatchObject({ done: true }) - await iterator.return?.() - releaseUnload.resolve(undefined) - await disposing - detachAgent() - lifecycle.detach() - await ctx.fiber.dispose() - expect(agentReads).toBe(0) - expect(sessionReads).toBe(0) - expect(outcome).toBe('idle') - }, - ) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 9a21341e26..ce2b4db320 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -146,10 +146,9 @@ describe('sessions domain schemas', () => { cacheReadTokens: 4_000, cacheWriteTokens: 500, contextTokens: 8_000, - contextWindow: 128_000, }, modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - }).metrics?.contextWindow).toBe(128_000) + }).metrics?.contextTokens).toBe(8_000) expect(() => sessionMetricsSchema.parse({ logRevision: 1, projectionRevision: 0, @@ -158,15 +157,6 @@ describe('sessions domain schemas', () => { cacheReadTokens: 0, cacheWriteTokens: 0, })).toThrow() - expect(() => sessionMetricsSchema.parse({ - logRevision: 1, - projectionRevision: 0, - uncachedInputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - contextWindow: 0, - })).toThrow() expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionModelsValueSchema.parse({ current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, @@ -344,6 +334,23 @@ describe('events frame schemas', () => { cacheWriteTokens: 40, }, }, + { + type: 'session/model-request', + sessionId: 's', + turn: 2, + step: 1, + provider: 'deepseek', + model: 'deepseek-chat', + contextWindow: 128_000, + }, + { + type: 'session/model-request', + sessionId: 's', + turn: 3, + step: 1, + provider: 'deepseek', + model: 'unknown-capacity', + }, { type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' }, { type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' }, { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, @@ -358,6 +365,8 @@ describe('events frame schemas', () => { { type: 'session/title', sessionId: 's', title: '', eventSeq: 0, updatedAt: 1 }, { type: 'session/title', sessionId: 's', title: 'x', eventSeq: -1, updatedAt: 1 }, { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0.5, updatedAt: 1 }, + { type: 'session/model-request', sessionId: 's', turn: 0, step: 1, provider: 'p', model: 'm' }, + { type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextWindow: 0 }, { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: 'now' }, { type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: Number.NaN }, ]) expect(() => muxFrameSchema.parse(invalid)).toThrow() diff --git a/packages/host/apiproxy/tests/session-metrics.spec.ts b/packages/host/apiproxy/tests/session-metrics.spec.ts index deb5f740fe..34cb1cb430 100644 --- a/packages/host/apiproxy/tests/session-metrics.spec.ts +++ b/packages/host/apiproxy/tests/session-metrics.spec.ts @@ -1,6 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { Agent, AgentLlmTarget } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import { affectsSessionMetrics, SessionMetricsProjector } from '../src/session-metrics.ts' @@ -29,16 +28,8 @@ function assistant( }, { surfaceOp: 'append' }) } -function agent(session: Session): Agent { - return { id: session.id, session } as Agent -} - -function settleAsyncWork(): Promise { - return new Promise((resolve) => { setImmediate(resolve) }) -} - describe('SessionMetricsProjector', () => { - it('filters text/reasoning stream deltas while retaining usage, headers, and surface mutations', () => { + it('filters text/reasoning deltas while retaining usage, headers, and surface mutations', () => { const session = new Session(SessionId('metrics-filter')) const text = session.append('assistant/chunk', { turn: 1, @@ -58,13 +49,16 @@ describe('SessionMetricsProjector', () => { content: [{ type: 'text', text: 'question' }], source: { kind: 'user' }, }, { surfaceOp: 'append' }) + const plain = session.append('step/start', { turn: 1, step: 1 }) + expect(affectsSessionMetrics(text)).toBe(false) expect(affectsSessionMetrics(usage)).toBe(true) expect(affectsSessionMetrics(header)).toBe(true) expect(affectsSessionMetrics(surface)).toBe(true) + expect(affectsSessionMetrics(plain)).toBe(false) }) - it('reconciles usage by turn:step, keeps cache writes disjoint, and survives a surface replacement', () => { + it('folds usage by turn and step while synchronous pressure follows surface replacement', () => { const ctx = new Context() ctx.provide('tokenMeter', { measure(session: Session) { @@ -83,11 +77,8 @@ describe('SessionMetricsProjector', () => { cacheWriteTokens: 8, }) - const current: AgentLlmTarget = { provider: 'test', model: 'alpha' } - const projector = new SessionMetricsProjector(ctx, () => current, () => {}) - const attached = agent(session) - const before = projector.snapshot(session, attached) - expect(before).toMatchObject({ + const projector = new SessionMetricsProjector(ctx) + expect(projector.snapshot(session)).toMatchObject({ uncachedInputTokens: 11, outputTokens: 3, cacheReadTokens: 89, @@ -104,8 +95,7 @@ describe('SessionMetricsProjector', () => { surfaceOp: { op: 'replace', start: first.seq, end: assistantSeq }, sourceEventSeqs: [first.seq, assistantSeq], }) - const compacted = projector.snapshot(session, attached) - expect(compacted).toMatchObject({ + expect(projector.snapshot(session)).toMatchObject({ uncachedInputTokens: 11, outputTokens: 3, cacheReadTokens: 89, @@ -113,357 +103,38 @@ describe('SessionMetricsProjector', () => { contextTokens: 100, }) - // A replayed usage event for the same step replaces the settled value. session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'usage', - usage: { - inputTokens: 12, - outputTokens: 4, - cacheReadTokens: 88, - cacheWriteTokens: 9, - }, + usage: { inputTokens: 12, outputTokens: 4, cacheReadTokens: 88, cacheWriteTokens: 9 }, }, }) - const replayed = projector.snapshot(session, attached) - expect(replayed).toMatchObject({ - uncachedInputTokens: 12, - outputTokens: 4, - cacheReadTokens: 88, - cacheWriteTokens: 9, - contextTokens: 100, - }) - assistant(session, 1, 2, { - inputTokens: 1_000, - outputTokens: 500, - cacheReadTokens: 2_000, - cacheWriteTokens: 3_000, - }) - - const after = projector.snapshot(session, attached) - expect(after).toMatchObject({ - logRevision: session.events.length, - projectionRevision: 3, - uncachedInputTokens: 1_012, - outputTokens: 504, - cacheReadTokens: 2_088, - cacheWriteTokens: 3_009, - contextTokens: 200, - }) - expect(after.uncachedInputTokens).not.toBe( - after.uncachedInputTokens + after.cacheReadTokens + after.cacheWriteTokens, - ) - }) - - it('publishes only the selected route capacity when asynchronous resolutions race', async () => { - const ctx = new Context() - const resolutions = new Map() - ctx.provide('tokenMeter', { measure: () => ({ totalTokens: 35_000 }) }) - ctx.provide('llm', { - resolveModelInfo(_provider: string, model: string, signal?: AbortSignal) { - return new Promise<{ context: { contextWindow: number } }>((resolve) => { - resolutions.set(model, { - signal, - resolve(contextWindow) { - resolve({ context: { contextWindow } }) - }, - }) - }) - }, - }) - const session = new Session(SessionId('capacity-race')) - const attached = agent(session) - let current: AgentLlmTarget = { provider: 'test', model: 'alpha' } - const resolved = vi.fn() - const projector = new SessionMetricsProjector(ctx, () => current, resolved) - - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(resolutions.has('alpha')).toBe(true) }) - expect(resolutions.get('alpha')?.signal?.aborted).toBe(false) - current = { provider: 'test', model: 'beta' } - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - expect(resolutions.get('alpha')?.signal?.aborted).toBe(true) - await vi.waitFor(() => { expect(resolutions.has('beta')).toBe(true) }) - expect(resolutions.get('beta')?.signal?.aborted).toBe(false) - - resolutions.get('alpha')?.resolve(64_000) - await Promise.resolve() - expect(resolved).not.toHaveBeenCalled() - resolutions.get('beta')?.resolve(128_000) - await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) - expect(projector.snapshot(session, attached)).toMatchObject({ - contextTokens: 35_000, - contextWindow: 128_000, - }) - }) - - it('retries a failed same-route capacity lookup only on the next snapshot', async () => { - const ctx = new Context() - const attempts: PromiseWithResolvers<{ context: { contextWindow: number } }>[] = [] - ctx.provide('llm', { - resolveModelInfo() { - const attempt = Promise.withResolvers<{ context: { contextWindow: number } }>() - attempts.push(attempt) - return attempt.promise - }, - }) - const session = new Session(SessionId('capacity-retry')) - const attached = agent(session) - const resolved = vi.fn() - const projector = new SessionMetricsProjector( - ctx, - () => ({ provider: 'test', model: 'alpha' }), - resolved, - ) - - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(attempts).toHaveLength(1) }) - attempts[0]?.reject(new Error('metadata temporarily unavailable')) - await settleAsyncWork() - expect(attempts).toHaveLength(1) - expect(resolved).not.toHaveBeenCalled() - - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - await settleAsyncWork() - expect(attempts).toHaveLength(2) - attempts[1]?.resolve({ context: { contextWindow: 128_000 } }) - await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) - expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) - }) - - it('aborts every active capacity on invalidation and resolves fresh generations', async () => { - const ctx = new Context() - const attempts: { - result: PromiseWithResolvers<{ context: { contextWindow: number } }> - signal: AbortSignal | undefined - }[] = [] - ctx.provide('llm', { - resolveModelInfo(_provider: string, _model: string, signal?: AbortSignal) { - const result = Promise.withResolvers<{ context: { contextWindow: number } }>() - attempts.push({ result, signal }) - return result.promise - }, - }) - const firstSession = new Session(SessionId('capacity-invalidation-first')) - const secondSession = new Session(SessionId('capacity-invalidation-second')) - const firstAgent = agent(firstSession) - const secondAgent = agent(secondSession) - const resolved = vi.fn() - const projector = new SessionMetricsProjector( - ctx, - () => ({ provider: 'test', model: 'alpha' }), - resolved, - ) - - expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined() - expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(attempts).toHaveLength(2) }) - expect(attempts.map(attempt => attempt.signal?.aborted)).toEqual([false, false]) - - projector.invalidateCapacities() - expect(attempts.map(attempt => attempt.signal?.aborted)).toEqual([true, true]) - attempts[0]?.result.resolve({ context: { contextWindow: 32_000 } }) - attempts[1]?.result.resolve({ context: { contextWindow: 64_000 } }) - await settleAsyncWork() - expect(resolved).not.toHaveBeenCalled() - - expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined() - expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(attempts).toHaveLength(4) }) - expect(attempts.slice(2).map(attempt => attempt.signal?.aborted)).toEqual([false, false]) - attempts[2]?.result.resolve({ context: { contextWindow: 128_000 } }) - attempts[3]?.result.resolve({ context: { contextWindow: 256_000 } }) - await vi.waitFor(() => { expect(resolved).toHaveBeenCalledTimes(2) }) - expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBe(128_000) - expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBe(256_000) - - projector.dispose() - expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined() - expect(attempts).toHaveLength(4) - }) - - it('skips adapter work invalidated before its deferred invocation', async () => { - const ctx = new Context() - const attempts: { - result: PromiseWithResolvers<{ context: { contextWindow: number } }> - signal: AbortSignal | undefined - }[] = [] - const resolveModelInfo = vi.fn(( - _provider: string, - _model: string, - signal?: AbortSignal, - ) => { - const result = Promise.withResolvers<{ context: { contextWindow: number } }>() - attempts.push({ result, signal }) - return result.promise - }) - ctx.provide('llm', { resolveModelInfo }) - const session = new Session(SessionId('capacity-pre-invocation-invalidation')) - const attached = agent(session) - const resolved = vi.fn() - const projector = new SessionMetricsProjector( - ctx, - () => ({ provider: 'test', model: 'alpha' }), - resolved, - ) - - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - projector.invalidateCapacities() - await settleAsyncWork() - expect(resolveModelInfo).not.toHaveBeenCalled() - expect(resolved).not.toHaveBeenCalled() - - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(attempts).toHaveLength(1) }) - expect(attempts[0]?.signal?.aborted).toBe(false) - attempts[0]?.result.resolve({ context: { contextWindow: 128_000 } }) - await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) - expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) - }) - - it('starts a fresh capacity generation when an unavailable route returns', async () => { - const ctx = new Context() - const resolutions: { - signal: AbortSignal | undefined - resolve(contextWindow: number): void - }[] = [] - ctx.provide('llm', { - resolveModelInfo(_provider: string, _model: string, signal?: AbortSignal) { - return new Promise<{ context: { contextWindow: number } }>((resolve) => { - resolutions.push({ - signal, - resolve(contextWindow) { - resolve({ context: { contextWindow } }) - }, - }) - }) - }, - }) - const session = new Session(SessionId('capacity-route-return')) - const attached = agent(session) - let current: AgentLlmTarget | undefined = { provider: 'test', model: 'alpha' } - const resolved = vi.fn() - const targetFor = vi.fn(() => current) - const projector = new SessionMetricsProjector(ctx, targetFor, resolved) - - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(resolutions).toHaveLength(1) }) - current = undefined - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - expect(resolutions[0]?.signal?.aborted).toBe(true) - resolutions[0]?.resolve(64_000) - await settleAsyncWork() - expect(resolved).not.toHaveBeenCalled() - current = { provider: 'test', model: 'alpha' } - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(resolutions).toHaveLength(2) }) - expect(resolutions[1]?.signal?.aborted).toBe(false) - resolutions[1]?.resolve(128_000) - await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) - expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) - }) - - it('omits current context fields when measurement or model metadata is unavailable', async () => { - const ctx = new Context() - ctx.provide('tokenMeter', { measure: () => { throw new Error('unmeasurable') } }) - ctx.provide('llm', { resolveModelInfo: () => Promise.reject(new Error('metadata unavailable')) }) - const session = new Session(SessionId('missing-metrics')) - const attached = agent(session) - const projector = new SessionMetricsProjector( - ctx, - () => ({ provider: 'test', model: 'missing' }), - () => {}, - ) - const metrics = projector.snapshot(session, attached) - expect(metrics.contextTokens).toBeUndefined() - expect(metrics.contextWindow).toBeUndefined() - await vi.waitFor(() => { - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - }) - }) - - it('keeps optional usage buckets at zero and tolerates absent host services or detached agents', async () => { - const ctx = new Context() - const session = new Session(SessionId('optional-metrics')) - assistant(session, 1, 0, { inputTokens: 7, outputTokens: 2 }) - const attached = agent(session) - const selected: { current?: AgentLlmTarget } = {} - const projector = new SessionMetricsProjector( - ctx, - () => selected.current, - () => {}, - ) + assistant(session, 1, 2, { inputTokens: 1_000, outputTokens: 500 }) expect(projector.snapshot(session)).toMatchObject({ - uncachedInputTokens: 7, - outputTokens: 2, - cacheReadTokens: 0, - cacheWriteTokens: 0, + logRevision: session.events.length, + projectionRevision: 2, + uncachedInputTokens: 1_012, + outputTokens: 504, + cacheReadTokens: 88, + cacheWriteTokens: 9, + contextTokens: 200, }) - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - selected.current = { provider: 'test', model: 'no-service' } - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - await Promise.resolve() }) - it('publishes a resolved route with no advertised capacity as unknown', async () => { - const ctx = new Context() - ctx.provide('llm', { resolveModelInfo: () => Promise.resolve({}) }) - const session = new Session(SessionId('no-capacity')) - const attached = agent(session) - const resolved = vi.fn() - const projector = new SessionMetricsProjector( - ctx, - () => ({ provider: 'test', model: 'metadata-without-context' }), - resolved, - ) + it('omits pressure when the token meter is absent or cannot measure the replay', () => { + const session = new Session(SessionId('metrics-pressure-unknown')) + const withoutMeter = new SessionMetricsProjector(new Context()).snapshot(session) + expect(withoutMeter.contextTokens).toBeUndefined() - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) - expect(projector.snapshot(session, attached).contextWindow).toBeUndefined() - }) - - it('ignores stale resolution failures and route metadata after the target moves', async () => { const ctx = new Context() - const resolutions = new Map() - ctx.provide('llm', { - resolveModelInfo(_provider: string, model: string) { - return new Promise<{ context: { contextWindow: number } }>((resolve, reject) => { - resolutions.set(model, { resolve, reject }) - }) + ctx.provide('tokenMeter', { + measure() { + throw new Error('unmeasurable replay') }, }) - const session = new Session(SessionId('stale-capacity')) - const attached = agent(session) - let current: AgentLlmTarget = { provider: 'test', model: 'alpha' } - const resolved = vi.fn() - const targetFor = vi.fn(() => current) - const projector = new SessionMetricsProjector(ctx, targetFor, resolved) - - projector.snapshot(session, attached) - await vi.waitFor(() => { expect(resolutions.has('alpha')).toBe(true) }) - current = { provider: 'test', model: 'route-moved-before-snapshot' } - resolutions.get('alpha')?.resolve({ context: { contextWindow: 64_000 } }) - await vi.waitFor(() => { expect(targetFor).toHaveBeenCalledTimes(2) }) - expect(resolved).not.toHaveBeenCalled() - - projector.snapshot(session, attached) - await vi.waitFor(() => { expect(resolutions.has('route-moved-before-snapshot')).toBe(true) }) - current = { provider: 'test', model: 'beta' } - projector.snapshot(session, attached) - await vi.waitFor(() => { expect(resolutions.has('beta')).toBe(true) }) - resolutions.get('route-moved-before-snapshot')?.reject(new Error('stale failure')) - await Promise.resolve() - resolutions.get('beta')?.resolve({ context: { contextWindow: 128_000 } }) - await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() }) - expect(projector.snapshot(session, attached).contextWindow).toBe(128_000) + expect(new SessionMetricsProjector(ctx).snapshot(session).contextTokens).toBeUndefined() }) }) diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index d35885f2a2..6ef87e4ab8 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.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 packages/llm/llm/README.md -README.md: 2328188e420df6de60f024982a31d37a858a303e -README.zh.md: 586767a9e790fa68d27673836700fd789ce6a180 +README.md: d28a5632a3fbdbf11c7dba2ee0c57a704f2ba6f4 +README.zh.md: fd93fa43d5bfabd6e8751d4efbad229096ced08d diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 2328188e42..d28a5632a3 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -16,7 +16,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters. - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` Validate an explicit effort and materialize an adapter-configured default without clamping. -- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config and capture its current adapter registration as one cancellable, one-shot call. +- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` Resolve a config plus available context metadata in one exact-model lookup and capture its current adapter registration as one cancellable, one-shot call. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. @@ -25,7 +25,7 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`. -Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. +Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally exposes the detached context metadata from that same lookup and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. Its dispatch observer runs after a final stream handle is constructed and before adapter iteration. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. ### Events diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 586767a9e7..fd93fa43d5 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -16,7 +16,7 @@ - `ctx.llm.listModels(provider: string): Promise` 发现某个已注册提供方当前公布的模型。 - `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理(reasoning)元数据;异步适配器可选地支持取消。 - `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。 -- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。 +- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise` 在一次精确模型查询中解析配置与可用上下文元数据,并将其当前适配器注册捕获为一次可取消、一次性调用。 - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` 将一次模型调用流式输出为原始 chunk(token 级 delta)。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。 `LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`。 @@ -25,7 +25,7 @@ 确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份、上下文或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 -推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速完成结算。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 +推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速完成结算。`prepareCall()` 还会公开同一次查询得到的脱耦上下文元数据,并让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。其分派观察器在最终流句柄构造完成后、适配器开始迭代前运行。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 ### 事件 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 103bab747f..12bd2ec6ea 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -10,6 +10,7 @@ import { Context, Service } from 'cordis' import type { GenerateOptions, LlmFailure, + LlmModelContext, LlmModelInfo, LlmResolvedModelInfo, LlmProviderInfo, @@ -111,14 +112,18 @@ export class LlmError extends HarnessError { export interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ readonly config: LlmCallConfig + /** Detached context metadata resolved with the registration-bound call. */ + readonly context?: LlmModelContext /** * Dispatch this call once through the registration captured during * preparation. The request's call-config fields must match {@link config}; * reuse or mismatch fails with `INVALID_PREPARED_CALL`. * @param options - fully assembled request carrying the prepared config. + * @param onDispatched - contained Agent-loop notification hook invoked after + * a stream handle is constructed and before its adapter is iterated. * @returns the chunk stream, including the `llm/stream` waterfall. */ - stream(options: GenerateOptions): AsyncIterable + stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable } /** @@ -392,15 +397,16 @@ export class LlmService extends Service { * @returns a detached config only when a default must be materialized. */ async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise { - return this.resolveCallConfigFor(this.registration(config.provider), config, signal) + return (await this.resolveCallFor(this.registration(config.provider), config, signal)).config } - private async resolveCallConfigFor( + private async resolveCallFor( registration: AdapterRegistration, config: LlmCallConfig, signal?: AbortSignal, - ): Promise { - const reasoning = (await this.resolveModelInfoFor(registration, config.model, signal)).reasoning + ): Promise<{ config: LlmCallConfig; context?: LlmModelContext }> { + const resolved = await this.resolveModelInfoFor(registration, config.model, signal) + const reasoning = resolved.reasoning const requested = config.reasoningEffort if (reasoning === undefined) { if (requested !== undefined) { @@ -409,17 +415,28 @@ export class LlmService extends Service { 'UNSUPPORTED_REASONING_EFFORT', ) } - return config + return { + config, + ...resolved.context === undefined ? {} : { context: resolved.context }, + } } const effective = requested ?? reasoning.defaultEffort - if (effective === undefined) return config + if (effective === undefined) { + return { + config, + ...resolved.context === undefined ? {} : { context: resolved.context }, + } + } if (!reasoning.efforts.some(effort => effort.id === effective)) { throw new LlmError( `provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`, 'UNSUPPORTED_REASONING_EFFORT', ) } - return requested === effective ? config : { ...config, reasoningEffort: effective } + return { + config: requested === effective ? config : { ...config, reasoningEffort: effective }, + ...resolved.context === undefined ? {} : { context: resolved.context }, + } } /** @@ -432,18 +449,25 @@ export class LlmService extends Service { */ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise { const registration = this.registration(config.provider) - const resolvedConfig = deepFreeze(structuredClone( - await this.resolveCallConfigFor(registration, config, signal), - )) + const resolved = await this.resolveCallFor(registration, config, signal) + const resolvedConfig = deepFreeze(structuredClone(resolved.config)) + const context = resolved.context === undefined + ? undefined + : Object.freeze(structuredClone(resolved.context)) let dispatched = false return Object.freeze({ config: resolvedConfig, - stream: (options: GenerateOptions): AsyncIterable => { + ...context === undefined ? {} : { context }, + stream: (options: GenerateOptions, onDispatched?: () => void): AsyncIterable => { if (dispatched) { throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL') } dispatched = true - return this.streamWithRegistration(options, { registration, config: resolvedConfig }) + return this.streamWithRegistration( + options, + { registration, config: resolvedConfig }, + onDispatched, + ) }, }) } @@ -478,25 +502,51 @@ export class LlmService extends Service { * so it cannot suppress the primary provider error. A downstream close awaits * adapter cleanup, whose failures remain ordinary untagged work. */ - private async * adapterStream( + private adapterStream( options: GenerateOptions, failures: AdapterFailureScope, prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, - ): AsyncGenerator { + onDispatched?: () => void, + ): AsyncIterable { + if (prepared === undefined) { + return this.resolveAndStream(options, failures, onDispatched) + } let iterator: AsyncIterator try { - const registration = prepared?.registration ?? this.registration(options.provider) + const registration = prepared.registration failures.retryPolicy = registration.retryPolicy - const resolvedConfig = prepared === undefined - ? await this.resolveCallConfigFor(registration, options, options.signal) - : prepared.config - if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) { + const resolvedConfig = prepared.config + if (!callConfigEquals(options, resolvedConfig)) { throw new LlmError( 'prepared LLM call config changed before adapter dispatch', 'INVALID_PREPARED_CALL', ) } - const resolvedOptions = prepared !== undefined || callConfigEquals(options, resolvedConfig) + const adapter = registration.adapter + const stream = adapter.stream(this.forAdapter(options, adapter)) + iterator = stream[Symbol.asyncIterator]() + } catch (error: unknown) { + return this.failedAdapterStream(markLlmAdapterFailure(failures, error)) + } + this.notifyDispatched(onDispatched) + return this.iterateAdapter(iterator, failures) + } + + private async * resolveAndStream( + options: GenerateOptions, + failures: AdapterFailureScope, + onDispatched?: () => void, + ): AsyncGenerator { + let iterator: AsyncIterator + try { + const registration = this.registration(options.provider) + failures.retryPolicy = registration.retryPolicy + const resolvedConfig = (await this.resolveCallFor( + registration, + options, + options.signal, + )).config + const resolvedOptions = callConfigEquals(options, resolvedConfig) ? options : Object.isFrozen(options) ? deepFreeze({ ...options, ...resolvedConfig }) @@ -507,7 +557,19 @@ export class LlmService extends Service { } catch (error: unknown) { throw markLlmAdapterFailure(failures, error) } + this.notifyDispatched(onDispatched) + yield* this.iterateAdapter(iterator, failures) + } + private async * failedAdapterStream(error: Error): AsyncGenerator { + await Promise.resolve() + throw error + } + + private async * iterateAdapter( + iterator: AsyncIterator, + failures: AdapterFailureScope, + ): AsyncGenerator { let completed = false let iterationFailed = false try { @@ -537,6 +599,15 @@ export class LlmService extends Service { } } + private notifyDispatched(onDispatched: (() => void) | undefined): void { + if (onDispatched === undefined) return + try { + onDispatched() + } catch (error: unknown) { + this.ctx.logger.warn(`llm dispatch observer threw: ${String(error)}`) + } + } + /** * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for @@ -548,23 +619,32 @@ export class LlmService extends Service { * agent-loop request recovery; middleware and nested-call failures remain * untagged for the outer call. * @param options - the full request; `options.provider` selects the adapter. + * @param onDispatched - contained Agent-loop notification hook invoked after + * a stream handle is constructed and before its adapter is iterated. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ - stream(options: GenerateOptions): AsyncIterable { - return this.streamWithRegistration(options) + stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable { + return this.streamWithRegistration(options, undefined, onDispatched) } private streamWithRegistration( options: GenerateOptions, prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, + onDispatched?: () => void, ): AsyncIterable { const failures: AdapterFailureScope = { failures: new WeakMap() } + let terminalEntered = false const stream = this.ctx.waterfall( this, 'llm/stream', options, - () => this.adapterStream(options, failures, prepared), + () => { + terminalEntered = true + return this.adapterStream(options, failures, prepared, onDispatched) + }, ) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- waterfall mutates this latch. + if (!terminalEntered) this.notifyDispatched(onDispatched) return bindAdapterFailureScope(stream, failures) } } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index dc4cf1d9c0..85d07be6fe 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1058,6 +1058,40 @@ describe('LlmService', () => { })).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' })) }) + it('reuses one exact-model lookup for prepared config and context metadata', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + let resolutions = 0 + const source = { contextWindow: 128_000 } + const adapter = new class extends ScriptedAdapter { + override resolveModel(provider: string, model: string): Promise { + resolutions += 1 + return Promise.resolve({ + provider, + id: model, + name: model, + context: source, + reasoning: { + efforts: [{ id: ReasoningEffortId('high'), name: 'High' }], + defaultEffort: ReasoningEffortId('high'), + }, + }) + } + }(SCRIPT) + ctx.llm.registerAdapter(['route'], adapter) + + const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' }) + source.contextWindow = 64_000 + expect(prepared.config.reasoningEffort).toBe(ReasoningEffortId('high')) + expect(prepared.context).toEqual({ contextWindow: 128_000 }) + expect(Object.isFrozen(prepared.context)).toBe(true) + for await (const _chunk of prepared.stream({ + ...prepared.config, + messages: [], + })) { /* drain */ } + expect(resolutions).toBe(1) + }) + it('passes cancellation through exact-model resolution', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b10ad9dfac..5a1eae1faa 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -217,6 +217,7 @@ const FOUNDATION_TYPE_NAMES = new Set([ /** Project types deliberately documented outside the core-data catalog. */ const TYPE_LINK_EXEMPTIONS: Readonly> = { AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md', + AgentModelRequest: 'event-local live request metadata is owned by packages/core/agent/README.md', BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts', From e6ce6abd7d4b3747cf3f7610c836fc3435dc20bf Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 19:03:45 +0800 Subject: [PATCH 12/47] refactor(llm): simplify live request telemetry (round 2) --- ...8-host-owned-web-session-metrics.i18n.yaml | 4 +- ...26-07-28-host-owned-web-session-metrics.md | 2 +- ...07-28-host-owned-web-session-metrics.zh.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/cordis-catalog/events.md | 12 +- docs/cordis-catalog/services.md | 6 +- .../llm-streaming.i18n.yaml | 4 +- docs/core-data-structures/llm-streaming.md | 6 +- docs/core-data-structures/llm-streaming.zh.md | 6 +- .../src/client/sessions/conversation.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 10 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 4 +- packages/core/agent-loop/src/agent.ts | 33 +++-- .../tests/request-reconstruction.spec.ts | 52 +++++++- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/types.ts | 18 +-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api/events.ts | 10 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/index.ts | 117 ++++-------------- packages/llm/llm/tests/service.spec.ts | 16 ++- 31 files changed, 159 insertions(+), 187 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml index 100837813e..4f7d187049 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.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/architecture/2026-07-28-host-owned-web-session-metrics.md -2026-07-28-host-owned-web-session-metrics.md: e37a7635cbdae65162308bf8a3a498b5071a4910 -2026-07-28-host-owned-web-session-metrics.zh.md: fd3e50c8cf8c0336a8cb2cd55f6629419bb8ad23 +2026-07-28-host-owned-web-session-metrics.md: bc66ee72dc278196af8bab65c5e118d8f5cf039c +2026-07-28-host-owned-web-session-metrics.zh.md: a103a8e24bd806ded6131b94f4c123c8d745513d diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md index e37a7635cb..bc66ee72dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md +++ b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md @@ -12,7 +12,7 @@ A Web stats line derived from the currently loaded conversation nodes is window- The Host owns one session-level metrics projection. It incrementally folds the complete durable event log, keys settled usage by `(turn, step)`, and replaces an earlier usage record for the same key instead of double-counting chunk and message forms. Uncached input, output, cache reads, and cache writes remain four disjoint cumulative buckets. Compaction can change the current prompt surface without erasing historical usage. -Current context pressure is the point-in-time `tokenMeter.measure(session).totalTokens`. Capacity instead belongs to the latest model request observed by the current live mux connection. `LlmService.prepareCall()` retains the context metadata obtained by the exact lookup that also validates reasoning/defaults, and the loop publishes it through one contained `agent/model-request` notification only after the final route has a successfully constructed stream handle. Failed or aborted iteration still counts as a dispatched request; preparation and synchronous construction failures do not. +Current context pressure is the point-in-time `tokenMeter.measure(session).totalTokens`. Capacity instead belongs to the latest model request attempt observed by the current live mux connection. `LlmService.prepareCall()` retains the context metadata obtained by the exact lookup that also validates reasoning/defaults. After the final provider/model is fixed and the outer `llm/stream` call returns a handle, the loop publishes one contained `agent/model-request` notification. This boundary observes an attempt, not proof of provider I/O: preparation or a synchronous outer waterfall failure emits nothing, while short-circuit handles and later lazy adapter construction, iteration failure, or abort still count. The tail `session.history` response carries durable usage and pressure, while older pages omit them. Live changes use `session/metrics` mux frames. Both forms carry a durable-log revision and a projection revision; the client accepts only nondecreasing revisions and preserves metrics across older-page prepend. diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md index fd3e50c8cf..a103a8e24b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md @@ -12,7 +12,7 @@ Web 统计行若根据当前加载的会话节点推导指标,其结果会随 Host 拥有一项会话级指标投影。它以增量方式归并完整的持久事件日志,按 `(turn, step)` 标识已结算用量;同一标识再次出现时,会替换较早的用量记录,而不会重复统计分片和消息两种形态。未缓存输入、输出、缓存读取与缓存写入保持为四个彼此独立的累计计数项。压缩可以改变当前提示词表层,但不会抹除历史用量。 -当前上下文压力是即时的 `tokenMeter.measure(session).totalTokens`。容量则属于当前实时 mux 连接观察到的最新模型请求。`LlmService.prepareCall()` 会保留同一次精确查询取得的上下文元数据,该查询也负责校验推理设置与默认值;仅在最终路由的流句柄成功构造后,循环才会通过一条失败会被收容的 `agent/model-request` 通知发布这些元数据。后续迭代失败或中止仍算作已分派请求;准备阶段失败和同步构造失败则不算。 +当前上下文压力是即时的 `tokenMeter.measure(session).totalTokens`。容量则属于当前实时 mux 连接观察到的最新模型请求尝试。`LlmService.prepareCall()` 会保留同一次精确查询取得的上下文元数据,该查询也负责校验推理设置与默认值。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发布一条失败会被收容的 `agent/model-request` 通知。这个边界观察到的是一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄以及之后的惰性适配器构造、迭代失败或中止仍会计入。 `session.history` 尾页响应携带持久用量与压力,较早页面则省略这两项。实时变更使用 `session/metrics` mux 帧。两种形式都携带持久日志修订号和投影修订号;客户端只接受不减小的修订号,并在向前加载较早页面时保留指标。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index cfe3106723..166f5388d6 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 docs/architecture.md -architecture.md: 52072633a0e81afce63c5e162dd1b0af7f6486ca -architecture.zh.md: f0122ece146c17366aa316cfb4ea4196a1db74bd +architecture.md: e9ae1e27d6f1f1170b5da4823f3e988f4e778022 +architecture.zh.md: 3a31a2e6694d39f38e9c88de44f59ce7f753871f diff --git a/docs/architecture.md b/docs/architecture.md index 52072633a0..e9ae1e27d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,7 @@ forever: assemble system prompt and tool schemas snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> prepare reasoning/default + context under turn signal -> log request/header -> construct llm/stream (frozen, registration-bound) -> agent/model-request (live, contained) -> iterate + agent/request (config only) -> prepare reasoning/default + context under turn signal -> log request/header -> obtain outer llm/stream handle (frozen request; prepared calls registration-bound) -> agent/model-request (live, contained attempt) -> iterate 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: @@ -151,7 +151,7 @@ Log-only events may sit between turns. Owners append through `Session`, flushing Messages use typed blocks from merge-extensible `ContentBlockMap`; the pattern also types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New blocks coordinate adapters, UI, compaction, token metering, and persistence; replay measurements live in [token-meter.md](core-data-structures/token-meter.md). -Streaming uses raw chunks and `BlockAssembler`. After final-stream construction, the loop emits contained, non-durable, non-replayed `agent/model-request` metadata. Adapters normalize failures; `agent/request-error` may retry. Remote adapters use per-read idle watchdogs. Replay crosses routes only through a shared adapter ([contract](core-data-structures/llm-streaming.md)). +Streaming uses chunks and `BlockAssembler`. When the outer `llm/stream` returns a handle, AgentLoop emits contained, non-durable, non-replayed `agent/model-request` attempt metadata—not proof of provider I/O. `agent/request-error` may retry. Replay crosses routes only through a shared adapter ([contract](core-data-structures/llm-streaming.md)). ## Extension And Composition diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index f0122ece14..3a31a2e669 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -92,7 +92,7 @@ forever: assemble system prompt and tool schemas snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> prepare reasoning/default + context under turn signal -> log request/header -> construct llm/stream (frozen, registration-bound) -> agent/model-request (live, contained) -> iterate + agent/request (config only) -> prepare reasoning/default + context under turn signal -> log request/header -> obtain outer llm/stream handle (frozen request; prepared calls registration-bound) -> agent/model-request (live, contained attempt) -> iterate 'assistant/chunk' 'assistant/message' schedule tool calls by ctx.tools.executionMode: @@ -151,7 +151,7 @@ idle inject: 消息使用从可合并扩展的 `ContentBlockMap` 派生的类型化块;同一模式也为 `MessageSource`、`FinishReason`、`TurnTrigger` 和 `TurnEndReason` 定义类型。新增块会协调适配器、UI、压缩、token 计量和持久化;回放计量见 [token-meter.md](core-data-structures/token-meter.md)。 -流式输出使用原始分片和 `BlockAssembler`。最终流构造完成后,循环会发出 `agent/model-request` 元数据;该通知的失败会被收容,元数据不会持久化或回放。适配器会规范化故障;`agent/request-error` 可以重试。远程适配器使用逐次读取空闲看门狗。回放仅通过共用适配器跨路由传递([契约](core-data-structures/llm-streaming.md))。 +流式输出使用分片和 `BlockAssembler`。外层 `llm/stream` 返回句柄时,AgentLoop 会发出 `agent/model-request` 尝试元数据;该通知的失败会被收容,元数据不会持久化或回放,但这并不能证明提供方 I/O 已开始。`agent/request-error` 可以重试。回放仅通过共用适配器跨路由传递([契约](core-data-structures/llm-streaming.md))。 ## 扩展与组合 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index e8e67da781..fe9ca59c9a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -166,15 +166,15 @@ Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/t ### `agent/model-request` — emit -One model request constructed its final stream handle and is about to iterate it. This live notification is not durable or replayed; failed or aborted iteration still has a dispatch, while preparation and synchronous stream-construction failures do not. Listener failures are contained and cannot affect the request. +One model request obtained its outer `llm/stream` handle and is about to iterate it. This observes an Agent-loop request attempt, not proof that provider I/O began. The notification is live, contained, and not replayed. Preparation or a synchronous outer waterfall failure emits nothing; failures or abortion after the handle returns still count. ```ts cordis-catalog /** - * One model request constructed its final stream handle and is about to - * iterate it. This live notification is not durable or replayed; failed or - * aborted iteration still has a dispatch, while preparation and - * synchronous stream-construction failures do not. Listener failures are - * contained and cannot affect the request. + * One model request obtained its outer `llm/stream` handle and is about to + * iterate it. This observes an Agent-loop request attempt, not proof that + * provider I/O began. The notification is live, contained, and not replayed. + * Preparation or a synchronous outer waterfall failure emits nothing; + * failures or abortion after the handle returns still count. * @param agent - the agent dispatching the model request. * @param turn - the open turn number. * @param step - the request's step number. diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9d00ef37a0..7572a3ffd7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -780,16 +780,14 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise void): AsyncIterable +stream(options: GenerateOptions): AsyncIterable ``` Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:194`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:192`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml index 6206f864cf..5b4865b2ca 100644 --- a/docs/core-data-structures/llm-streaming.i18n.yaml +++ b/docs/core-data-structures/llm-streaming.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 docs/core-data-structures/llm-streaming.md -llm-streaming.md: 89628ebb96a2e8eec5209635cd92859427df4a8d -llm-streaming.zh.md: 9c8fcc1f24b970f3a7cdd7cd08d9ef3b934b4543 +llm-streaming.md: 30230d208c582463b3680d760c27f33a71b4cf6f +llm-streaming.zh.md: 32b3485f967b849371f916f6a669b97c97701cc1 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 89628ebb96..30230d208c 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -161,7 +161,7 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, and to retain detached context metadata from that exact lookup. Its optional observer runs after a final stream handle is constructed and before adapter iteration. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, and to retain detached context metadata from that exact lookup. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -175,11 +175,9 @@ interface PreparedLlmCall { * preparation. The request's call-config fields must match {@link config}; * reuse or mismatch fails with `INVALID_PREPARED_CALL`. * @param options - fully assembled request carrying the prepared config. - * @param onDispatched - contained Agent-loop notification hook invoked after - * a stream handle is constructed and before its adapter is iterated. * @returns the chunk stream, including the `llm/stream` waterfall. */ - stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable + stream(options: GenerateOptions): AsyncIterable } ``` diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md index 9c8fcc1f24..32b3485f96 100644 --- a/docs/core-data-structures/llm-streaming.zh.md +++ b/docs/core-data-structures/llm-streaming.zh.md @@ -161,7 +161,7 @@ declare class BlockAssembler { ## seam -`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,并保留来自同一次精确查询的分离上下文元数据。其可选观察器在最终流句柄构造完成后、适配器开始迭代前运行。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 +`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,并保留来自同一次精确查询的分离上下文元数据。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。 ```ts type-equiv /** One model call whose config and adapter registration were resolved together. */ @@ -175,11 +175,9 @@ interface PreparedLlmCall { * preparation. The request's call-config fields must match {@link config}; * reuse or mismatch fails with `INVALID_PREPARED_CALL`. * @param options - fully assembled request carrying the prepared config. - * @param onDispatched - contained Agent-loop notification hook invoked after - * a stream handle is constructed and before its adapter is iterated. * @returns the chunk stream, including the `llm/stream` waterfall. */ - stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable + stream(options: GenerateOptions): AsyncIterable } ``` diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 4ced165e7a..3277f42979 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -17,7 +17,7 @@ export type { TodoItem } * connection overlaid for presentation. */ export interface ConversationMetrics extends SessionMetrics { - /** Latest dispatched-request capacity; absent until observed or after reset/clear. */ + /** Latest observed request-attempt capacity; absent until observed or after reset/clear. */ contextWindow?: number } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 897935dad6..05d631c87c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -397,8 +397,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Resolve one call under its current adapter registration. The returned\n * one-shot handle keeps that registration across header logging and dispatch,\n * so HMR cannot combine one adapter\'s capability result with another adapter.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a prepared config and its registration-bound stream entry point.\n */', }, { - signature: 'stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable', - jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @param onDispatched - contained Agent-loop notification hook invoked after\n * a stream handle is constructed and before its adapter is iterated.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', + signature: 'stream(options: GenerateOptions): AsyncIterable', + jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', }, ], }, @@ -1052,8 +1052,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/model-request', mode: 'emit', signature: '\'agent/model-request\'(this: Scoped, agent: Agent, turn: number, step: number, request: AgentModelRequest): void', - jsDoc: '/**\n * One model request constructed its final stream handle and is about to\n * iterate it. This live notification is not durable or replayed; failed or\n * aborted iteration still has a dispatch, while preparation and\n * synchronous stream-construction failures do not. Listener failures are\n * contained and cannot affect the request.\n * @param agent - the agent dispatching the model request.\n * @param turn - the open turn number.\n * @param step - the request\'s step number.\n * @param request - final route plus registration-bound context capacity.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', - summary: 'One model request constructed its final stream handle and is about to iterate it.', + jsDoc: '/**\n * One model request obtained its outer `llm/stream` handle and is about to\n * iterate it. This observes an Agent-loop request attempt, not proof that\n * provider I/O began. The notification is live, contained, and not replayed.\n * Preparation or a synchronous outer waterfall failure emits nothing;\n * failures or abortion after the handle returns still count.\n * @param agent - the agent dispatching the model request.\n * @param turn - the open turn number.\n * @param step - the request\'s step number.\n * @param request - final route plus registration-bound context capacity.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'One model request obtained its outer `llm/stream` handle and is about to iterate it.', }, { name: 'agent/prompt-submit', @@ -1810,7 +1810,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PreparedLlmCall', - declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly context?: LlmModelContext;\n stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable;\n}', + declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly context?: LlmModelContext;\n stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'PreparedReferencedMessage', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 8b72206a87..c6911a4c55 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.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 packages/core/agent-loop/README.md -README.md: 39eaafd3abb2faef045c1a2f694d8dffe95c28b0 -README.zh.md: f8cc972e957fe95f652d54e859f8e5411288db51 +README.md: 6e23d9f543998d5c0266c73197ee52ede51260aa +README.zh.md: cf561a03281f80b7afd5f245752e9ebf70333a8b diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 39eaafd3ab..6e23d9f543 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -62,7 +62,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. -After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort, materialize its configured default, and retain available context metadata from that same exact-model lookup under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. After the final stream handle is constructed and before adapter iteration, the loop emits one contained live `agent/model-request` notification with turn, step, final provider/model, and optional registration-bound capacity. Preparation or synchronous stream-construction failures emit nothing; later failure or abortion remains an observed dispatch. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently. +After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort, materialize its configured default, and retain available context metadata from that same exact-model lookup under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. Once the final provider/model is fixed and the outer `llm/stream` call returns a handle, the loop emits one contained live `agent/model-request` notification with turn, step, route, and optional registration-bound capacity. This is an observed Agent-loop attempt, not proof of provider I/O: preparation or a synchronous outer waterfall failure emits nothing, while a short-circuit handle or later lazy adapter construction, failure, or abortion still counts. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently. Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index f8cc972e95..cf561a0328 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -62,7 +62,7 @@ interface Config { 每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。 -在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终流句柄构造完成后、适配器开始迭代前,循环会发出一条失败会被收容的实时 `agent/model-request` 通知,其中包含轮次、步骤、最终提供方/模型,以及可选的、与注册项绑定的容量。准备阶段失败或同步流构造失败不会发出通知;之后即使失败或中止,该请求仍视为已观察到的分派。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。 +在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发出一条失败会被收容的实时 `agent/model-request` 通知,其中包含轮次、步骤、路由,以及可选的、与注册项绑定的容量。这是 agent loop 观察到的一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄或之后的惰性适配器构造、失败或中止仍会计入。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。 插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 @@ -89,7 +89,7 @@ interface Config { #### Token 影响 -每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall(瀑布式事件)可以改变最终请求,并使其监听器负责保持协议连贯。 +每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall 可以改变最终请求,并使其监听器负责保持协议连贯。 #### KV Cache 影响 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 9c930912ba..65f3fe4b2a 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -487,24 +487,21 @@ export class ReactLoopAgent implements Agent { const assembler = new BlockAssembler() const chunkSeqs: number[] = [] - const onDispatched = (): void => { - emitAgentEvent( - this.loopCtx, - this, - 'agent/model-request', - turn, - step, - { - provider: request.provider, - model: request.model, - ...preparedCall?.context === undefined - ? {} - : { contextWindow: preparedCall.context.contextWindow }, - }, - ) - } - const stream = preparedCall?.stream(request, onDispatched) - ?? this.loopCtx.llm.stream(request, onDispatched) + const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) + emitAgentEvent( + this.loopCtx, + this, + 'agent/model-request', + turn, + step, + { + provider: request.provider, + model: request.model, + ...preparedCall?.context === undefined + ? {} + : { contextWindow: preparedCall.context.contextWindow }, + }, + ) try { for await (const chunk of stream) { signal.throwIfAborted() diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 9da0e30f81..51ddc4decf 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -293,6 +293,7 @@ describe('request stability across the loop', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) let observed: GenerateOptions | undefined + let observedRequest: { provider: string; model: string; contextWindow?: number } | undefined ctx.on('llm/stream', (options) => { observed = options return (async function* () { @@ -303,11 +304,15 @@ describe('request stability across the loop', () => { provider: 'listener', model: 'virtual', }) + ctx.on('agent/model-request', (subject, _turn, _step, request) => { + if (subject === agent) observedRequest = { ...request } + }) send(agent, 'go') await waitForIdle(ctx, agent) expect(observed).toMatchObject({ provider: 'listener', model: 'virtual' }) + expect(observedRequest).toEqual({ provider: 'listener', model: 'virtual' }) expect(agent.session.requestHeader()?.config).toEqual({ provider: 'listener', model: 'virtual', @@ -318,7 +323,7 @@ describe('request stability across the loop', () => { }) }) - it('notifies one contained live model-request edge only after successful stream construction', async () => { + it('notifies one contained request attempt after the outer stream handle returns', async () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -341,7 +346,9 @@ describe('request stability across the loop', () => { } override stream(options: GenerateOptions): AsyncIterable { - if (options.model === 'sync-failure') throw new LlmError('construction failed', 'CONSTRUCTION') + if (options.model === 'lazy-sync-failure') { + throw new LlmError('lazy construction failed', 'CONSTRUCTION') + } if (options.model === 'async-failure') { return { [Symbol.asyncIterator]: () => ({ @@ -359,6 +366,22 @@ describe('request stability across the loop', () => { provider: 'mock', model: 'capacity', }) + const returnedHandles = new Set() + ctx.on('llm/stream', (options, next) => { + if (options.model === 'outer-failure') { + throw new Error('outer waterfall failed before returning a handle') + } + if (options.model === 'lazy-sync-failure') { + const stream = (async function* () { + yield* next() + })() + returnedHandles.add(options.model) + return stream + } + const stream = next() + returnedHandles.add(options.model) + return stream + }) const observed: { turn: number step: number @@ -366,18 +389,27 @@ describe('request stability across the loop', () => { model: string contextWindow?: number }[] = [] + const observedBeforeHandleReturn: string[] = [] ctx.on('agent/model-request', (subject) => { if (subject === agent) throw new Error('observer failed') }) ctx.on('agent/model-request', (subject, turn, step, request) => { - if (subject === agent) observed.push({ turn, step, ...request }) + if (subject !== agent) return + if (!returnedHandles.has(request.model)) observedBeforeHandleReturn.push(request.model) + observed.push({ turn, step, ...request }) }) ctx.on('agent/request', async (_subject, turn, _step, _signal, next) => ({ ...await next(), - model: ['capacity', 'unknown', 'async-failure', 'sync-failure'][turn - 1]!, + model: [ + 'capacity', + 'unknown', + 'async-failure', + 'lazy-sync-failure', + 'outer-failure', + ][turn - 1]!, })) - for (const prompt of ['one', 'two', 'three', 'four']) { + for (const prompt of ['one', 'two', 'three', 'four', 'five']) { send(agent, prompt) await waitForIdle(ctx, agent) } @@ -402,8 +434,16 @@ describe('request stability across the loop', () => { provider: 'mock', model: 'async-failure', }, + { + turn: 4, + step: 1, + provider: 'mock', + model: 'lazy-sync-failure', + }, ]) - expect(resolutions).toBe(4) + expect(resolutions).toBe(5) + expect(observedBeforeHandleReturn).toEqual([]) + expect(returnedHandles.has('outer-failure')).toBe(false) }) it('a compaction replace rewrites the resend, and the log explains it', async () => { diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 00465d7021..0d537a147a 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.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 packages/core/agent/README.md -README.md: 304347d32df4546389ee2b45230d4e80acc60942 -README.zh.md: d9adbe20015aadbad26288d43df92f80a3f550bf +README.md: ad79e101974eb187df3093092a282fc6e5850857 +README.zh.md: 84849df23dfdc668f0d18c05ed6d9fd4d8c315d6 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 304347d32d..ad79e10197 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -48,7 +48,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while the contained `agent/model-request` notification reports a final dispatched route and optional registration-bound context capacity without becoming durable state. `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. +Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while the contained `agent/model-request` notification reports the route and optional registration-bound context capacity for an attempt whose outer stream handle returned. It is neither durable state nor proof of provider I/O. `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement. `PromptDecision.additionalContexts` is an array so every context keeps its own source. Allowed prompt content and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index d9adbe2001..84849df23d 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -48,7 +48,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。 -大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;`agent/model-request` 是失败会被收容的通知,它会报告最终已分派路由及可选的、与注册项绑定的上下文容量,但不会成为持久状态。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 +大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;`agent/model-request` 是失败会被收容的通知,它会报告外层流句柄已返回的尝试所用路由,以及可选的、与注册项绑定的上下文容量。它既不是持久状态,也不能证明提供方 I/O 已开始。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。 `PromptDecision.additionalContexts` 是数组,因此每个上下文都保留自己的来源。获准的提示词内容与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;返回的允许决策是权威来源。 diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 5aac3152b2..f7b1447352 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -116,13 +116,13 @@ export type PromptDecision = /** Model-request failure with an optional machine-routable provider code. */ export type RequestError = Error & { code?: string } -/** Live metadata for one model request that reached adapter dispatch. */ +/** Live metadata for one model request whose outer stream handle was obtained. */ export interface AgentModelRequest { - /** Final registered provider route. */ + /** Final request provider route; a short-circuit listener may own it. */ readonly provider: string - /** Final adapter-owned model id. */ + /** Final request model id; a short-circuit listener may own it. */ readonly model: string - /** Registration-bound context capacity when the adapter exposed one. */ + /** Registration-bound context capacity when preparation exposed one. */ readonly contextWindow?: number } @@ -371,11 +371,11 @@ declare module 'cordis' { */ 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise): Promise /** - * One model request constructed its final stream handle and is about to - * iterate it. This live notification is not durable or replayed; failed or - * aborted iteration still has a dispatch, while preparation and - * synchronous stream-construction failures do not. Listener failures are - * contained and cannot affect the request. + * One model request obtained its outer `llm/stream` handle and is about to + * iterate it. This observes an Agent-loop request attempt, not proof that + * provider I/O began. The notification is live, contained, and not replayed. + * Preparation or a synchronous outer waterfall failure emits nothing; + * failures or abortion after the handle returns still count. * @param agent - the agent dispatching the model request. * @param turn - the open turn number. * @param step - the request's step number. diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 2bab687635..681058233b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: d3d1711242f71832f63eb570242fdf9e149cc988 -README.zh.md: 4e52058c1795ea23a9ca8ed46b8890c85f129eb4 +README.md: 263cb2f661e2f5b91cc4885ac99fe3afe61bbfbc +README.zh.md: 400031b7d6d668d6ec7d2922b8a6abb855fe5b7b diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d3d1711242..263cb2f661 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -22,7 +22,7 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create` `session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries session-level projections the page window cannot supply: the in-flight partial's chunk events; `todos`, the latest `todo/write` whole-list projection; and `metrics`, full-log usage deduplicated by `(turn, step)` plus current token-meter pressure. Older pages omit the session-level projections. Live `session/metrics` mux frames carry monotonic log/projection revisions, so clients reject stale frames and preserve the counters while prepending older pages. Cache reads and writes remain disjoint buckets; the cache-hit denominator is uncached input plus cache reads. -Context capacity uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an actual request reaches dispatch. It carries turn, step, final provider/model, and optional capacity only to mux connections already open at that instant. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay prior capacity; a frame without capacity explicitly clears the earlier connection-local value. +Context capacity uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an observed request attempt returns its outer stream handle. This boundary does not prove provider I/O began. The frame carries turn, step, final provider/model, and optional capacity only to mux connections already open at that instant. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay prior capacity; a frame without capacity explicitly clears the earlier connection-local value. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 4e52058c17..400031b7d6 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -22,7 +22,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)携带页窗口本身无法提供的会话级投影:进行中局部消息的分片事件;`todos`,即最后一次 `todo/write` 的整表投影;以及 `metrics`,即按 `(turn, step)` 去重的完整日志用量与当前 token 计量压力。较早的页面省略会话级投影。实时 `session/metrics` mux 帧携带单调递增的日志修订号与投影修订号,因此客户端会拒绝陈旧帧,并在向前加载较早页面时保留计数器。缓存读取与缓存写入保持为彼此独立的计数项;缓存命中率的分母是未缓存输入加缓存读取。 -上下文容量使用独立的临时 `session/model-request` mux 帧;实际请求到达分派点后,该帧由失败会被收容的 Agent 通知发出。该帧携带轮次、步骤、最终提供方/模型与可选容量,且只发送给当时已经打开的 mux 连接。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放先前的容量;不带容量的帧会显式清除较早的连接本地值。 +上下文容量使用独立的临时 `session/model-request` mux 帧;观察到的请求尝试返回外层流句柄后,该帧由失败会被收容的 Agent 通知发出。这个边界不能证明提供方 I/O 已开始。该帧携带轮次、步骤、最终提供方/模型与可选容量,且只发送给当时已经打开的 mux 连接。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放先前的容量;不带容量的帧会显式清除较早的连接本地值。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index c4babb6017..2eec50c257 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -60,11 +60,11 @@ export type MuxFrame = | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number } | { type: 'session/metrics'; sessionId: SessionId; metrics: SessionMetrics } /** - * One model request observed by this already-open mux connection after its - * final route and stream handle were resolved. This frame is transient: mux - * baselines, reconnects, and session history never replay it. An absent - * `contextWindow` explicitly clears a capacity observed from an earlier - * request on the same connection. + * One request attempt observed by this already-open mux connection after its + * final route and outer `llm/stream` handle were obtained. This does not prove + * provider I/O began. The frame is transient: mux baselines, reconnects, and + * session history never replay it. An absent `contextWindow` explicitly clears + * a capacity observed from an earlier request on the same connection. */ | { type: 'session/model-request' diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 6ef87e4ab8..fd8147dc3f 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.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 packages/llm/llm/README.md -README.md: d28a5632a3fbdbf11c7dba2ee0c57a704f2ba6f4 -README.zh.md: fd93fa43d5bfabd6e8751d4efbad229096ced08d +README.md: 5d0459b722c4c5f472231ee86e775bbc4ff7b7f2 +README.zh.md: 3dd7153d63c8c4b40b737ed58d5d6d1a29f3153a diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index d28a5632a3..5d0459b722 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -25,7 +25,7 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`. -Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally exposes the detached context metadata from that same lookup and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. Its dispatch observer runs after a final stream handle is constructed and before adapter iteration. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. +Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally exposes the detached context metadata from that same lookup and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. ### Events diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index fd93fa43d5..3dd7153d63 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -25,7 +25,7 @@ 确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context` 或 `reasoning` 字段只表示相应能力不可用。无效的身份、上下文或推理元数据会以 `INVALID_MODEL_INFO`、`INVALID_MODEL_CONTEXT` 或 `INVALID_MODEL_REASONING` 失败。 -推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速完成结算。`prepareCall()` 还会公开同一次查询得到的脱耦上下文元数据,并让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。其分派观察器在最终流句柄构造完成后、适配器开始迭代前运行。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 +推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal,并且必须在取消后迅速完成结算。`prepareCall()` 还会公开同一次查询得到的脱耦上下文元数据,并让精确适配器注册跨越请求头记录和最终分派,因此 HMR(热模块替换)不会将一个适配器的能力结果与另一个适配器的请求混用;复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。 ### 事件 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 12bd2ec6ea..8120ac5085 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -119,11 +119,9 @@ export interface PreparedLlmCall { * preparation. The request's call-config fields must match {@link config}; * reuse or mismatch fails with `INVALID_PREPARED_CALL`. * @param options - fully assembled request carrying the prepared config. - * @param onDispatched - contained Agent-loop notification hook invoked after - * a stream handle is constructed and before its adapter is iterated. * @returns the chunk stream, including the `llm/stream` waterfall. */ - stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable + stream(options: GenerateOptions): AsyncIterable } /** @@ -408,6 +406,7 @@ export class LlmService extends Service { const resolved = await this.resolveModelInfoFor(registration, config.model, signal) const reasoning = resolved.reasoning const requested = config.reasoningEffort + let resolvedConfig = config if (reasoning === undefined) { if (requested !== undefined) { throw new LlmError( @@ -415,26 +414,20 @@ export class LlmService extends Service { 'UNSUPPORTED_REASONING_EFFORT', ) } - return { - config, - ...resolved.context === undefined ? {} : { context: resolved.context }, + } else { + const effective = requested ?? reasoning.defaultEffort + if (effective !== undefined) { + if (!reasoning.efforts.some(effort => effort.id === effective)) { + throw new LlmError( + `provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) + } + if (requested !== effective) resolvedConfig = { ...config, reasoningEffort: effective } } } - const effective = requested ?? reasoning.defaultEffort - if (effective === undefined) { - return { - config, - ...resolved.context === undefined ? {} : { context: resolved.context }, - } - } - if (!reasoning.efforts.some(effort => effort.id === effective)) { - throw new LlmError( - `provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`, - 'UNSUPPORTED_REASONING_EFFORT', - ) - } return { - config: requested === effective ? config : { ...config, reasoningEffort: effective }, + config: resolvedConfig, ...resolved.context === undefined ? {} : { context: resolved.context }, } } @@ -458,16 +451,12 @@ export class LlmService extends Service { return Object.freeze({ config: resolvedConfig, ...context === undefined ? {} : { context }, - stream: (options: GenerateOptions, onDispatched?: () => void): AsyncIterable => { + stream: (options: GenerateOptions): AsyncIterable => { if (dispatched) { throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL') } dispatched = true - return this.streamWithRegistration( - options, - { registration, config: resolvedConfig }, - onDispatched, - ) + return this.streamWithRegistration(options, { registration, config: resolvedConfig }) }, }) } @@ -502,50 +491,24 @@ export class LlmService extends Service { * so it cannot suppress the primary provider error. A downstream close awaits * adapter cleanup, whose failures remain ordinary untagged work. */ - private adapterStream( + private async * adapterStream( options: GenerateOptions, failures: AdapterFailureScope, prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, - onDispatched?: () => void, - ): AsyncIterable { - if (prepared === undefined) { - return this.resolveAndStream(options, failures, onDispatched) - } + ): AsyncGenerator { let iterator: AsyncIterator try { - const registration = prepared.registration + const registration = prepared?.registration ?? this.registration(options.provider) failures.retryPolicy = registration.retryPolicy - const resolvedConfig = prepared.config - if (!callConfigEquals(options, resolvedConfig)) { + const resolvedConfig = prepared === undefined + ? (await this.resolveCallFor(registration, options, options.signal)).config + : prepared.config + if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) { throw new LlmError( 'prepared LLM call config changed before adapter dispatch', 'INVALID_PREPARED_CALL', ) } - const adapter = registration.adapter - const stream = adapter.stream(this.forAdapter(options, adapter)) - iterator = stream[Symbol.asyncIterator]() - } catch (error: unknown) { - return this.failedAdapterStream(markLlmAdapterFailure(failures, error)) - } - this.notifyDispatched(onDispatched) - return this.iterateAdapter(iterator, failures) - } - - private async * resolveAndStream( - options: GenerateOptions, - failures: AdapterFailureScope, - onDispatched?: () => void, - ): AsyncGenerator { - let iterator: AsyncIterator - try { - const registration = this.registration(options.provider) - failures.retryPolicy = registration.retryPolicy - const resolvedConfig = (await this.resolveCallFor( - registration, - options, - options.signal, - )).config const resolvedOptions = callConfigEquals(options, resolvedConfig) ? options : Object.isFrozen(options) @@ -557,19 +520,7 @@ export class LlmService extends Service { } catch (error: unknown) { throw markLlmAdapterFailure(failures, error) } - this.notifyDispatched(onDispatched) - yield* this.iterateAdapter(iterator, failures) - } - private async * failedAdapterStream(error: Error): AsyncGenerator { - await Promise.resolve() - throw error - } - - private async * iterateAdapter( - iterator: AsyncIterator, - failures: AdapterFailureScope, - ): AsyncGenerator { let completed = false let iterationFailed = false try { @@ -599,15 +550,6 @@ export class LlmService extends Service { } } - private notifyDispatched(onDispatched: (() => void) | undefined): void { - if (onDispatched === undefined) return - try { - onDispatched() - } catch (error: unknown) { - this.ctx.logger.warn(`llm dispatch observer threw: ${String(error)}`) - } - } - /** * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for @@ -619,32 +561,23 @@ export class LlmService extends Service { * agent-loop request recovery; middleware and nested-call failures remain * untagged for the outer call. * @param options - the full request; `options.provider` selects the adapter. - * @param onDispatched - contained Agent-loop notification hook invoked after - * a stream handle is constructed and before its adapter is iterated. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ - stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable { - return this.streamWithRegistration(options, undefined, onDispatched) + stream(options: GenerateOptions): AsyncIterable { + return this.streamWithRegistration(options) } private streamWithRegistration( options: GenerateOptions, prepared?: { registration: AdapterRegistration; config: LlmCallConfig }, - onDispatched?: () => void, ): AsyncIterable { const failures: AdapterFailureScope = { failures: new WeakMap() } - let terminalEntered = false const stream = this.ctx.waterfall( this, 'llm/stream', options, - () => { - terminalEntered = true - return this.adapterStream(options, failures, prepared, onDispatched) - }, + () => this.adapterStream(options, failures, prepared), ) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- waterfall mutates this latch. - if (!terminalEntered) this.notifyDispatched(onDispatched) return bindAdapterFailureScope(stream, failures) } } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 85d07be6fe..b0372b298e 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1070,11 +1070,14 @@ describe('LlmService', () => { provider, id: model, name: model, + description: 'Resolved model', context: source, - reasoning: { - efforts: [{ id: ReasoningEffortId('high'), name: 'High' }], - defaultEffort: ReasoningEffortId('high'), - }, + reasoning: model === 'no-default' + ? { efforts: [{ id: ReasoningEffortId('high'), name: 'High' }] } + : { + efforts: [{ id: ReasoningEffortId('high'), name: 'High' }], + defaultEffort: ReasoningEffortId('high'), + }, }) } }(SCRIPT) @@ -1090,6 +1093,11 @@ describe('LlmService', () => { messages: [], })) { /* drain */ } expect(resolutions).toBe(1) + + const noDefault = await ctx.llm.prepareCall({ provider: 'route', model: 'no-default' }) + expect(noDefault.config).toEqual({ provider: 'route', model: 'no-default' }) + expect(noDefault.context).toEqual({ contextWindow: 64_000 }) + expect(resolutions).toBe(2) }) it('passes cancellation through exact-model resolution', async () => { From fc6eb4e7a3598c1380f0dd7200f8a043fc9bb2a6 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 19:44:29 +0800 Subject: [PATCH 13/47] fix(web): retain live capacity before session creation --- .../runtime/src/client/sessions/manager.ts | 16 +++ .../runtime/src/client/sessions/session.ts | 9 +- packages/client/runtime/tests/manager.spec.ts | 121 ++++++++++++++++++ 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 66a0d00dd9..b6daecb97c 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -57,6 +57,12 @@ export class SessionManager { * drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these * frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */ private readonly pendingBuffers = new Map[]>() + /** + * Latest model capacity observed for an uninstantiated session on the + * current mux generation. Unlike durable history, this transient frame + * cannot be backfilled when get() lazily creates the Session. + */ + private readonly modelRequestContextWindows = new Map() /** Per-session projection value stores, retained independently of instance arrival (the * title-snapshot precedent, generalized): push frames land here whether or not the Session * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the @@ -160,6 +166,7 @@ export class SessionManager { } private createSession(sessionId: SessionId): Session { + const modelRequestContextWindow = this.modelRequestContextWindows.get(sessionId) return new Session(sessionId, this.api, { // The sender's local first-send flip mirrors into the list row so the // session surfaces (lists filter on blank) before any host frame lands. @@ -167,6 +174,7 @@ export class SessionManager { this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) }, projections: this.projectionStore(sessionId), + ...(modelRequestContextWindow === undefined ? {} : { modelRequestContextWindow }), }) } @@ -328,7 +336,14 @@ export class SessionManager { this.notifier.markDirty() return } + if (frame.type === 'session/model-request') { + // Transient and non-replayable: retain the latest capacity until lazy + // instantiation. An absent value explicitly clears an earlier one. + if (frame.contextWindow === undefined) this.modelRequestContextWindows.delete(frame.sessionId) + else this.modelRequestContextWindows.set(frame.sessionId, frame.contextWindow) + } if (frame.type === 'session/subscribed') { + this.modelRequestContextWindows.delete(frame.sessionId) // Rows past the host's durable baseline rode state a restart lost; drop // them so last-wins cannot pin a phantom value over recomputed truth. this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) @@ -391,6 +406,7 @@ export class SessionManager { this.recordMutation({ kind: 'remove', sessionId: frame.sessionId }) this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation + this.modelRequestContextWindows.delete(frame.sessionId) // connection-local request capacity dies with the Host session this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance return } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index f3f4891de2..8c4cee798d 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -43,6 +43,8 @@ export interface SessionOptions { * private store (bare object-layer construction). */ projections?: ProjectionValueStore + /** Model capacity already observed on this mux generation before lazy construction. */ + modelRequestContextWindow?: number } /** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */ @@ -172,6 +174,7 @@ export class Session implements ObservableSnapshot { private readonly options: SessionOptions = {}, ) { this.projections = options.projections ?? new ProjectionValueStore() + this.contextWindow = options.modelRequestContextWindow this.snapshotCache = this.buildSnapshot() } @@ -479,10 +482,12 @@ export class Session implements ObservableSnapshot { this.notifier.markDirty() } - /** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */ + /** host/session-removed relay: flag the resident snapshot and clear connection-local capacity. */ handleRemoved(): void { + const changed = !this.removed || this.contextWindow !== undefined this.removed = true - this.notifier.markDirty() + this.contextWindow = undefined + if (changed) this.notifier.markDirty() } /** diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 2923cd0d3c..410058228d 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -41,6 +41,127 @@ describe('instances', () => { expect(manager.get(S2).getSnapshot().pending).toEqual([]) }) + it('retains the latest transient model capacity until lazy instantiation', () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + manager.handleMuxEnvelope({ + rpcId: 'request-1' as never, + payload: { + type: 'session/model-request', + sessionId: S1, + turn: 1, + step: 1, + provider: 'test', + model: 'alpha', + contextWindow: 128_000, + }, + }) + manager.handleMuxEnvelope({ + rpcId: 'request-2' as never, + payload: { + type: 'session/model-request', + sessionId: S1, + turn: 1, + step: 2, + provider: 'test', + model: 'beta', + contextWindow: 256_000, + }, + }) + + expect(manager.get(S1).getSnapshot().modelRequestContextWindow).toBe(256_000) + }) + + it('retains explicit capacity clearing before lazy instantiation', () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + manager.handleMuxEnvelope({ + rpcId: 'request-with-capacity' as never, + payload: { + type: 'session/model-request', + sessionId: S1, + turn: 1, + step: 1, + provider: 'test', + model: 'alpha', + contextWindow: 128_000, + }, + }) + manager.handleMuxEnvelope({ + rpcId: 'request-without-capacity' as never, + payload: { + type: 'session/model-request', + sessionId: S1, + turn: 1, + step: 2, + provider: 'test', + model: 'unknown-capacity', + }, + }) + + expect(manager.get(S1).getSnapshot().modelRequestContextWindow).toBeUndefined() + }) + + it('clears retained capacity on subscribed and resident capacity on removal', () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + manager.handleMuxEnvelope({ + rpcId: 'request-before-subscribe' as never, + payload: { + type: 'session/model-request', + sessionId: S1, + turn: 1, + step: 1, + provider: 'test', + model: 'alpha', + contextWindow: 128_000, + }, + }) + manager.handleMuxEnvelope({ + rpcId: 'subscribed' as never, + payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 0 }, + }) + const session = manager.get(S1) + expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + + manager.handleMuxEnvelope({ + rpcId: 'request-after-subscribe' as never, + payload: { + type: 'session/model-request', + sessionId: S1, + turn: 1, + step: 2, + provider: 'test', + model: 'beta', + contextWindow: 256_000, + }, + }) + expect(session.getSnapshot().modelRequestContextWindow).toBe(256_000) + manager.handleHostEnvelope({ + rpcId: 'removed' as never, + payload: { type: 'host/session-removed', sessionId: S1 }, + }) + expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + + manager.handleMuxEnvelope({ + rpcId: 'request-before-lazy-removal' as never, + payload: { + type: 'session/model-request', + sessionId: S2, + turn: 1, + step: 1, + provider: 'test', + model: 'gamma', + contextWindow: 64_000, + }, + }) + manager.handleHostEnvelope({ + rpcId: 'lazy-removed' as never, + payload: { type: 'host/session-removed', sessionId: S2 }, + }) + expect(manager.get(S2).getSnapshot().modelRequestContextWindow).toBeUndefined() + }) + it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => { const api = new FakeApiClient() const manager = new SessionManager(api) From 47205cbb82f095dbcbd42506ca99c4b86c0c5e32 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 20:17:28 +0800 Subject: [PATCH 14/47] fix(web): preserve reconnect metrics baseline --- .../runtime/src/client/sessions/session.ts | 10 +++--- packages/client/runtime/tests/session.spec.ts | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 1471aae91e..2eec667e46 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -309,11 +309,10 @@ export class Session implements ObservableSnapshot { * in-flight open first — its history request rode the dead connection and must not settle * the fresh generation into 'error' (audit S4). */ async resync(): Promise { - // The queue mirror is NOT cleared here: onConnected (which drives resync) - // races the mux frames — the fresh generation's baseline may have landed - // already, and the host never resends it. The mirror re-baselines on the - // session/subscribed frame instead (same stream as the queue snapshot - // that follows it, so ordering is guaranteed). + // Queue, metrics, and request capacity are NOT cleared here: onConnected + // (which drives resync) races the mux frames — fresh-generation state may + // have landed already, and the host never resends it. session/subscribed + // owns the generation reset before the queue snapshot and metrics frames. if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) this.openGeneration++ this.openPromise = null @@ -322,7 +321,6 @@ export class Session implements ObservableSnapshot { this.events = [] this.views = [] this.baseSeq = 0 - this.metrics = null // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. this.pending.clear() diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 5f01dc9cc2..cbce270597 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -813,6 +813,40 @@ describe('remaining branches', () => { }) describe('resync', () => { + it('preserves fresh-generation metrics that arrive before a failing history refresh', async () => { + const { api, session } = makeSession() + const oldMetrics = metrics(8, 10) + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'), false, undefined, oldMetrics) + await session.open() + expect(session.getSnapshot().metrics).toBe(oldMetrics) + + session.handleMuxEnvelope('sub' as never, { + type: 'session/subscribed', + sessionId: SID, + lastSeq: 5, + }) + expect(session.getSnapshot().metrics).toBeNull() + + const freshMetrics = metrics(0, 10, { contextTokens: 20 }) + session.handleMuxEnvelope('fresh-metrics' as never, { + type: 'session/metrics', + sessionId: SID, + metrics: freshMetrics, + }) + api.onHistory = () => Promise.resolve(err({ + code: 'internal', + message: 'history refresh failed', + details: {}, + })) + + await session.resync() + + expect(session.getSnapshot()).toMatchObject({ + openState: 'error', + metrics: freshMetrics, + }) + }) + it('rebuilds the window and clears pending; cold instances no-op', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) From ef12895cf1c82cf2e6d233924bfc9df03020df57 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 20:28:01 +0800 Subject: [PATCH 15/47] fix(web): reset live metrics between connections --- .../connection/src/client/connection.ts | 9 ++- .../connection/tests/connection.spec.ts | 52 +++++++++++++++++- packages/client/runtime/src/client/index.ts | 1 + .../runtime/src/client/sessions/manager.ts | 6 ++ .../runtime/src/client/sessions/service.ts | 5 ++ .../runtime/src/client/sessions/session.ts | 8 +++ .../client/runtime/tests/client-apply.spec.ts | 47 ++++++++++++++++ packages/client/runtime/tests/manager.spec.ts | 55 ++++++++++++++++++- 8 files changed, 178 insertions(+), 5 deletions(-) diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 6eb6491e2f..a15312c9f9 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -46,6 +46,8 @@ export interface ConnectionSinks { onHostEnvelope?: (envelope: RpcRequest) => void /** After each connection generation is established (both streams open + describe succeeded), first connect included. */ onConnected?: () => void + /** After every failed generation closes and before retry starts. Not emitted when the controller is stopped. */ + onDisconnected?: () => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ onStateChange?: (state: ConnectionState) => void @@ -120,8 +122,8 @@ export class ConnectionController { if (gen === this.generation && !ac.signal.aborted) ac.abort() resolve() } - void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle) - void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle) + void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, ac.signal, settle) + void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, ac.signal, settle) }) try { @@ -147,6 +149,7 @@ export class ConnectionController { await failed if (!this.isRunning()) return + this.callSink(this.sinks.onDisconnected) this.emitState('reconnecting') this.attempt += 1 console.warn(`[web-runtime] connection lost, retry #${this.attempt}`) @@ -165,10 +168,12 @@ export class ConnectionController { private async pumpStream( stream: AsyncIterable>, sink: ((envelope: RpcRequest) => void) | undefined, + signal: AbortSignal, onEnd: () => void, ): Promise { try { for await (const envelope of stream) { + if (signal.aborted) break if (envelope.payload.type === 'stream/error') break if (sink !== undefined) this.callSink(() => { sink(envelope) }) } diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts index 4de4a31f25..9c54fcbcff 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '../src/client/api.ts' +import type { IApiClient, SessionId } from '../src/client/api.ts' import type { ConnectionState } from '../src/client/connection.ts' import { ConnectionController } from '../src/client/connection.ts' import { FakeApiClient, deferred, ok } from './fake-api.ts' @@ -104,6 +104,51 @@ describe('connection lifecycle', () => { } }) + it('drops a sibling stream frame buffered behind a generation failure', async () => { + const api = new FakeApiClient() + const lateMux = deferred() + const originalEvents = api.events + Object.defineProperty(api, 'events', { + value: { + host: (...args: Parameters) => originalEvents.host(...args), + mux: (_payload: unknown, _signal: AbortSignal, onOpen?: () => void) => (async function* () { + onOpen?.() + await lateMux.promise + yield { rpcId: 'late-mux' as never, payload: subscribedFrame(2) } + })(), + } satisfies IApiClient['events'], + }) + const muxSeen: number[] = [] + let connected = 0 + let disconnected = 0 + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController(api, { + onMuxEnvelope: (envelope) => { + if (envelope.payload.type === 'session/subscribed') muxSeen.push(envelope.payload.lastSeq) + }, + onConnected: () => { connected++ }, + onDisconnected: () => { + disconnected++ + lateMux.resolve(undefined) + controller.stop() + }, + }, FAST) + controller.start() + try { + await vi.waitFor(() => { expect(connected).toBe(1) }) + api.pushHost({ + type: 'stream/error', + error: { code: 'internal', message: 'host stream failed', details: {} }, + }) + await vi.waitFor(() => { expect(disconnected).toBe(1) }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(muxSeen).toEqual([]) + } finally { + controller.stop() + warnSpy.mockRestore() + } + }) + it('isolates sink exceptions from the pump', async () => { const api = new FakeApiClient() const seen: string[] = [] @@ -181,7 +226,7 @@ describe('connection lifecycle', () => { } }) - it('deduplicates consecutive reconnecting emissions across two straight failures', async () => { + it('reports every failed generation while deduplicating consecutive reconnecting state', async () => { const api = new FakeApiClient() const gate = deferred>>() let describeCalls = 0 @@ -190,10 +235,12 @@ describe('connection lifecycle', () => { return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise } const states: ConnectionState[] = [] + let disconnected = 0 let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const controller = new ConnectionController(api, { onConnected: () => { connected++ }, + onDisconnected: () => { disconnected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() @@ -201,6 +248,7 @@ describe('connection lifecycle', () => { await vi.waitFor(() => { expect(describeCalls).toBe(3) }) gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 })) await vi.waitFor(() => { expect(connected).toBe(1) }) + expect(disconnected).toBe(2) expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission } finally { controller.stop() diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 48514a9df4..f971331ba8 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -143,6 +143,7 @@ export function apply(ctx: Context): void { workspaces.handleConnected() ctx.emit('connection/reset') }, + onDisconnected: () => { sessions.handleReconnecting() }, }) ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop') } diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b6daecb97c..3443f60dd2 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -430,6 +430,12 @@ export class SessionManager { for (const session of this.sessions.values()) void session.resync() } + /** Before a replacement stream generation, discard values the Host does not replay. */ + handleReconnecting(): void { + this.modelRequestContextWindows.clear() + for (const session of this.sessions.values()) session.handleReconnecting() + } + private buildListSnapshot(): SessionListSnapshot { const merged: TitledSessionSummary[] = this.summaries.map((summary) => { // List rows read the generic 'title' projection key (host-computed unit diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 19e022d450..e342450b94 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -393,6 +393,11 @@ export class SessionsService { this.manager.handleConnected() } + /** Clear connection-local Session state before the next stream generation starts. */ + handleReconnecting(): void { + this.manager.handleReconnecting() + } + /** * Create a session on the host. Resolution guarantee: by the time the * promise resolves, the created session is in the list store and diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 2eec667e46..49035736dc 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -481,6 +481,14 @@ export class Session implements ObservableSnapshot { this.notifier.markDirty() } + /** Connection-loss boundary: clear values that are not replayed before the next stream starts. */ + handleReconnecting(): void { + if (this.metrics === null && this.contextWindow === undefined) return + this.metrics = null + this.contextWindow = undefined + this.notifier.markDirty() + } + /** host/session-removed relay: flag the resident snapshot and clear connection-local capacity. */ handleRemoved(): void { const changed = !this.removed || this.contextWindow !== undefined diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index d5b29f10a9..7399c46f6b 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -102,6 +102,53 @@ describe('runtime client apply', () => { expect(bench.api.callsOf('session.create')).toHaveLength(1) }) + it('clears connection-local Session state on disconnect but not connected', async () => { + const bench = await mount() + const sessions = bench.ctx.get('sessions') as SessionsService + bench.sinks?.onHostEnvelope?.({ + rpcId: 'session' as never, + payload: { type: 'host/session-added', blank: true, sessionId: 's-state' } as never, + }) + await Promise.resolve() + const session = sessions.binding('s-state' as never)?.session + if (session === undefined) throw new Error('session binding missing') + const currentMetrics = { + projectionRevision: 4, + logRevision: 10, + uncachedInputTokens: 10, + outputTokens: 4, + cacheReadTokens: 90, + cacheWriteTokens: 3, + contextTokens: 35, + } + bench.sinks?.onMuxEnvelope?.({ + rpcId: 'metrics' as never, + payload: { type: 'session/metrics', sessionId: 's-state', metrics: currentMetrics } as never, + }) + bench.sinks?.onMuxEnvelope?.({ + rpcId: 'capacity' as never, + payload: { + type: 'session/model-request', + sessionId: 's-state', + turn: 1, + step: 1, + provider: 'test', + model: 'alpha', + contextWindow: 128_000, + } as never, + }) + + bench.sinks?.onConnected?.() + expect(session.getSnapshot()).toMatchObject({ + metrics: currentMetrics, + modelRequestContextWindow: 128_000, + }) + + bench.sinks?.onDisconnected?.() + expect(session.getSnapshot().metrics).toBeNull() + expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + }) + it('stops the stream loop when the plugin fiber unloads', async () => { const bench = await mount() const fiber = [...bench.ctx.registry.values()].find(f => f.name?.includes('client')) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 410058228d..874e0be477 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId, SessionMetrics } from '@deepseek-ai/dsh-client-connection/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { entries, plainTurn } from './event-script.ts' @@ -162,6 +162,59 @@ describe('instances', () => { expect(manager.get(S2).getSnapshot().modelRequestContextWindow).toBeUndefined() }) + it('clears resident metrics and capacity plus lazy capacity before reconnect', () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + const session = manager.get(S1) + const currentMetrics: SessionMetrics = { + projectionRevision: 4, + logRevision: 10, + uncachedInputTokens: 10, + outputTokens: 4, + cacheReadTokens: 90, + cacheWriteTokens: 3, + contextTokens: 35, + } + manager.handleMuxEnvelope({ + rpcId: 'metrics' as never, + payload: { type: 'session/metrics', sessionId: S1, metrics: currentMetrics }, + }) + manager.handleMuxEnvelope({ + rpcId: 'resident-capacity' as never, + payload: { + type: 'session/model-request', + sessionId: S1, + turn: 1, + step: 1, + provider: 'test', + model: 'resident', + contextWindow: 128_000, + }, + }) + manager.handleMuxEnvelope({ + rpcId: 'lazy-capacity' as never, + payload: { + type: 'session/model-request', + sessionId: S2, + turn: 1, + step: 1, + provider: 'test', + model: 'lazy', + contextWindow: 256_000, + }, + }) + expect(session.getSnapshot()).toMatchObject({ + metrics: currentMetrics, + modelRequestContextWindow: 128_000, + }) + + manager.handleReconnecting() + + expect(session.getSnapshot().metrics).toBeNull() + expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + expect(manager.get(S2).getSnapshot().modelRequestContextWindow).toBeUndefined() + }) + it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => { const api = new FakeApiClient() const manager = new SessionManager(api) From c2543de85b1b94e42f32d2cb3dcc6a4883545f17 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 28 Jul 2026 20:43:26 +0800 Subject: [PATCH 16/47] fix(web): fence stale history after disconnect --- .../runtime/src/client/sessions/session.ts | 18 ++- packages/client/runtime/tests/session.spec.ts | 111 ++++++++++++++++-- 2 files changed, 115 insertions(+), 14 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 49035736dc..6708202276 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -82,9 +82,8 @@ export class Session implements ObservableSnapshot { private openState: OpenState = 'cold' private openError: RpcError | null = null private openPromise: Promise | null = null - /** Bumped by resync to invalidate an in-flight doOpen: a reconnect must rebuild, never adopt - * a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen - * passes drop all writes once the generation moves on. */ + /** Bumped at disconnect and resync to invalidate in-flight history work: a reconnect must + * rebuild, never adopt a pre-disconnect response (audit S4). */ private openGeneration = 0 private loadingOlder = false private readonly foldAdapter = new FoldAdapter() @@ -270,12 +269,14 @@ export class Session implements ObservableSnapshot { /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */ async loadOlder(): Promise { if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return + const generation = this.openGeneration this.loadingOlder = true this.notifier.markDirty() try { const { result } = await this.api.sessions.history({ sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES, }) + if (generation !== this.openGeneration) return if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded) const older = result.value.events if (older.length === 0) { @@ -299,8 +300,10 @@ export class Session implements ObservableSnapshot { } catch (error) { console.error('[web-runtime] loadOlder failed:', error) } finally { - this.loadingOlder = false - this.notifier.markDirty() + if (generation === this.openGeneration) { + this.loadingOlder = false + this.notifier.markDirty() + } } } @@ -321,6 +324,8 @@ export class Session implements ObservableSnapshot { this.events = [] this.views = [] this.baseSeq = 0 + this.loadingOlder = false + this.stitching = false // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. this.pending.clear() @@ -483,6 +488,7 @@ export class Session implements ObservableSnapshot { /** Connection-loss boundary: clear values that are not replayed before the next stream starts. */ handleReconnecting(): void { + this.openGeneration++ if (this.metrics === null && this.contextWindow === undefined) return this.metrics = null this.contextWindow = undefined @@ -649,7 +655,7 @@ export class Session implements ObservableSnapshot { } catch (error) { console.error('[web-runtime] gap repair failed:', error) } finally { - this.stitching = false + if (generation === this.openGeneration) this.stitching = false } } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index cbce270597..d879362990 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -392,6 +392,27 @@ describe('paging', () => { await Promise.all([first, second]) expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two }) + + it('drops an older page from the disconnected generation', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(6, 1, '新问', '新答'), true) + await session.open() + const stale = deferred>>() + api.onHistory = () => stale.promise + const loading = session.loadOlder() + + session.handleReconnecting() + stale.resolve(ok({ + events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[], + hasMore: false, + })) + await loading + expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9]) + + api.onHistory = () => histResponse(plainTurn(12, 2, '重连问', '重连答')) + await session.resync() + expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([13, 15]) + }) }) describe('prompt and cancel errors', () => { @@ -733,22 +754,49 @@ describe('remaining branches', () => { expect(session.getSnapshot().openState).toBe('open') }) - it('drops a gap repair superseded by a full resync while its pull was in flight', async () => { + it('drops a stale gap repair without clearing a newer generation repair', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() - const repairPull = deferred>>() - api.onHistory = () => repairPull.promise + const staleRepair = deferred>>() + api.onHistory = () => staleRepair.promise session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap + session.handleReconnecting() api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd')) - const resynced = session.resync() // bumps the generation - repairPull.resolve(ok({ + await session.resync() + + const freshRepair = deferred>>() + let freshRepairCalls = 0 + api.onHistory = () => { + freshRepairCalls++ + return freshRepair.promise + } + session.handleMuxEnvelope('fresh-gap' as never, { + type: 'session/event', + sessionId: SID, + event: ev.user(15, '新洞'), + }) + expect(freshRepairCalls).toBe(1) + + staleRepair.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false, - modelTarget: { provider: 'deepseek', model: 'stale' }, })) // repair result: stale, dropped - await resynced - expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) + await Promise.resolve() + session.handleMuxEnvelope('fresh-buffer' as never, { + type: 'session/event', + sessionId: SID, + event: ev.user(16, '继续缓存'), + }) + expect(freshRepairCalls).toBe(1) // stale finally did not clear the newer stitching owner + + freshRepair.resolve(ok({ + events: entries([...plainTurn(6, 1, 'c', 'd'), ...plainTurn(12, 2, 'e', 'f')]) as never[], + hasMore: false, + })) + await vi.waitFor(() => { + expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9, 13, 15]) + }) }) it('successful cancel leaves no promptError; tool/result for an unknown callId is a no-op', async () => { @@ -813,6 +861,53 @@ describe('remaining branches', () => { }) describe('resync', () => { + it('fences pre-disconnect history behind a fresh mux metrics baseline', async () => { + const { api, session } = makeSession() + const stale = deferred>>() + api.onHistory = () => stale.promise + const opening = session.open() + const oldLiveMetrics = metrics(8, 10) + session.handleMuxEnvelope('old-metrics' as never, { + type: 'session/metrics', + sessionId: SID, + metrics: oldLiveMetrics, + }) + session.handleMuxEnvelope('old-capacity' as never, { + type: 'session/model-request', + sessionId: SID, + turn: 1, + step: 1, + provider: 'test', + model: 'old', + contextWindow: 128_000, + }) + + session.handleReconnecting() + expect(session.getSnapshot().metrics).toBeNull() + expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + const freshMetrics = metrics(0, 1, { contextTokens: 20 }) + session.handleMuxEnvelope('fresh-metrics' as never, { + type: 'session/metrics', + sessionId: SID, + metrics: freshMetrics, + }) + + stale.resolve(ok({ + events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[], + hasMore: false, + metrics: metrics(99, 99, { contextTokens: 999 }), + })) + await opening + expect(session.getSnapshot().nodes).toEqual([]) + expect(session.getSnapshot().metrics).toBe(freshMetrics) + + api.onHistory = () => histResponse(plainTurn(6, 1, '新问', '新答')) + await session.resync() + expect(session.getSnapshot().openState).toBe('open') + expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9]) + expect(session.getSnapshot().metrics).toBe(freshMetrics) + }) + it('preserves fresh-generation metrics that arrive before a failing history refresh', async () => { const { api, session } = makeSession() const oldMetrics = metrics(8, 10) From e37cb233362266acda5c6db3bbd6f2940b887abf Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 29 Jul 2026 14:35:24 +0800 Subject: [PATCH 17/47] feat(token-meter): project durable token usage --- packages/llm/token-meter/package.json | 10 +- packages/llm/token-meter/src/client.ts | 7 + packages/llm/token-meter/src/index.ts | 9 + packages/llm/token-meter/src/projection.ts | 25 ++ packages/llm/token-meter/src/types.ts | 2 + .../llm/token-meter/src/usage-projection.ts | 100 ++++++++ .../tests/token-usage-projection.spec.ts | 222 ++++++++++++++++++ packages/llm/token-meter/tsconfig.json | 3 + pnpm-lock.yaml | 6 + 9 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 packages/llm/token-meter/src/client.ts create mode 100644 packages/llm/token-meter/src/projection.ts create mode 100644 packages/llm/token-meter/src/usage-projection.ts create mode 100644 packages/llm/token-meter/tests/token-usage-projection.spec.ts diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index dadd5e8f8d..99e90e4c3b 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/client.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -30,15 +35,18 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.18.0" + "schemastery": "^3.18.0", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/llm/token-meter/src/client.ts b/packages/llm/token-meter/src/client.ts new file mode 100644 index 0000000000..1bc02e3073 --- /dev/null +++ b/packages/llm/token-meter/src/client.ts @@ -0,0 +1,7 @@ +/** + * Client-namespace projection of token-meter's browser-safe types. + * + * @module @deepseek-ai/dsh-token-meter/client + */ + +export type * from './projection.ts' diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 533ebd2453..9b269b5cae 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -10,12 +10,15 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import type { EpochHeader, Session, SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session' +// Type-only: resolves the optional projection registry Context seam. +import type {} from '@deepseek-ai/dsh-session-projection' import type { TokenMeasurement, TokenMeasurementBaseline, TokenMeterConfig, TokenSurfaceNode, } from './types.ts' +import { tokenUsageProjectionDefinition } from './usage-projection.ts' export type * from './types.ts' @@ -90,6 +93,12 @@ export class TokenMeterService extends Service { super(ctx, 'tokenMeter') validateConfigKeys(config) + // Projection registration is an optional child: headless and TUI + // compositions without the generic registry keep the meter's old shape. + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition) + }) + // Readers catch up independently, while eager observation bounds ordinary // read latency without creating state for sessions no consumer has read. ctx.on('session/event', (session) => { diff --git a/packages/llm/token-meter/src/projection.ts b/packages/llm/token-meter/src/projection.ts new file mode 100644 index 0000000000..93c52297e8 --- /dev/null +++ b/packages/llm/token-meter/src/projection.ts @@ -0,0 +1,25 @@ +/** + * Pure client-safe token-usage projection vocabulary. + * + * @module @deepseek-ai/dsh-token-meter/projection + */ + +/** + * Durable cumulative provider usage for a complete session log. + * + * The four buckets are disjoint. In particular, reasoning tokens are already + * included in `outputTokens` and are not accumulated again. + */ +export interface TokenUsageProjection { + uncachedInputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Provider-reported usage accumulated across the complete durable log. */ + tokenUsage: TokenUsageProjection + } +} diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index 255425b639..15d2d50c41 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -6,6 +6,8 @@ import type { TokenUsage } from '@deepseek-ai/dsh-llm' +export type { TokenUsageProjection } from './projection.ts' + /** Token-meter plugin configuration; the fixed estimator has no settings. */ export type TokenMeterConfig = Record diff --git a/packages/llm/token-meter/src/usage-projection.ts b/packages/llm/token-meter/src/usage-projection.ts new file mode 100644 index 0000000000..bbd231fa01 --- /dev/null +++ b/packages/llm/token-meter/src/usage-projection.ts @@ -0,0 +1,100 @@ +/** + * Pure fold for durable provider-reported token usage. + */ + +import { z } from 'zod' +import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { TokenUsageProjection } from './projection.ts' + +interface UsageSample { + turn: number + step: number + buckets: TokenUsageProjection +} + +interface TokenUsageState { + totals: TokenUsageProjection + last: UsageSample | null +} + +const zeroBuckets = (): TokenUsageProjection => ({ + uncachedInputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, +}) + +const bucketsFrom = (usage: TokenUsage): TokenUsageProjection => ({ + uncachedInputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheReadTokens: usage.cacheReadTokens ?? 0, + cacheWriteTokens: usage.cacheWriteTokens ?? 0, +}) + +const bucketsEqual = (left: TokenUsageProjection, right: TokenUsageProjection): boolean => + left.uncachedInputTokens === right.uncachedInputTokens + && left.outputTokens === right.outputTokens + && left.cacheReadTokens === right.cacheReadTokens + && left.cacheWriteTokens === right.cacheWriteTokens + +const addReplacing = ( + totals: TokenUsageProjection, + previous: TokenUsageProjection | undefined, + next: TokenUsageProjection, +): TokenUsageProjection => ({ + uncachedInputTokens: totals.uncachedInputTokens - (previous?.uncachedInputTokens ?? 0) + next.uncachedInputTokens, + outputTokens: totals.outputTokens - (previous?.outputTokens ?? 0) + next.outputTokens, + cacheReadTokens: totals.cacheReadTokens - (previous?.cacheReadTokens ?? 0) + next.cacheReadTokens, + cacheWriteTokens: totals.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) + next.cacheWriteTokens, +}) + +const projectionSchema = z.object({ + uncachedInputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), + cacheReadTokens: z.number().int().nonnegative(), + cacheWriteTokens: z.number().int().nonnegative(), +}).strict() + +/** + * Token-meter's session projection unit. + * + * Usage chunks provide an early sample that survives a later request failure; + * an assistant message provides the final sample for the same turn/step. A + * repeated sample replaces that step's earlier value instead of double + * counting it. + */ +export const tokenUsageProjectionDefinition: +ProjectionDefinition<'tokenUsage', TokenUsageState> = { + key: 'tokenUsage', + schema: projectionSchema, + init: () => ({ totals: zeroBuckets(), last: null }), + apply: (state, event) => { + let turn: number + let step: number + let usage: TokenUsage + if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') { + ;({ turn, step } = event.data) + usage = event.data.chunk.usage + } else if (event.type === 'assistant/message' && event.data.usage !== undefined) { + ;({ turn, step, usage } = event.data) + } else { + return state + } + + const buckets = bucketsFrom(usage) + const previous = state.last !== null + && state.last.turn === turn + && state.last.step === step + ? state.last.buckets + : undefined + if (previous !== undefined && bucketsEqual(previous, buckets)) return state + + return { + totals: addReplacing(state.totals, previous, buckets), + last: { turn, step, buckets }, + } + }, + view: state => state.totals, + stateVersion: 1, +} diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts new file mode 100644 index 0000000000..07f745736a --- /dev/null +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import TokenMeterService from '@deepseek-ai/dsh-token-meter' +import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client' + +const ZERO: TokenUsageProjection = { + uncachedInputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, +} + +async function harness(): Promise<{ + ctx: Context + session: Session + meterFiber: Awaited> +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const meterFiber = await ctx.plugin(TokenMeterService) + return { ctx, session: ctx.sessions.create(), meterFiber } +} + +function startStep(session: Session, turn: number, step: number): void { + session.append('step/start', { turn, step }) +} + +function usageChunk( + session: Session, + usage: TokenUsage, + turn: number, + step: number, +): number { + return session.append('assistant/chunk', { + turn, + step, + chunk: { type: 'usage', usage }, + }).seq +} + +function finalUsage( + session: Session, + usage: TokenUsage, + turn: number, + step: number, + sourceSeqs: number[], +): void { + session.append('assistant/message', { + turn, + step, + message: createMessage({ + role: 'assistant', + content: [], + source: { kind: 'model', provider: 'mock', model: 'mock' }, + }), + usage, + }, { surfaceOp: 'append', sourceEventSeqs: sourceSeqs }) + session.append('step/end', { turn, step }) +} + +const projected = (ctx: Context, session: Session): TokenUsageProjection => { + const value = ctx.sessionProjections.snapshot(session).values.tokenUsage + if (value === undefined) throw new Error('tokenUsage projection is not registered') + return value +} + +describe('tokenUsage session projection', () => { + it('serves zero buckets for an empty log', async () => { + const { ctx, session } = await harness() + expect(projected(ctx, session)).toEqual(ZERO) + }) + + it('does not count a usage chunk and identical final usage twice', async () => { + const { ctx, session } = await harness() + const changes: unknown[] = [] + ctx.sessionProjections.onChanged((_session, key, value) => { + if (key === 'tokenUsage') changes.push(value) + }) + const usage = { + inputTokens: 10, + outputTokens: 4, + cacheReadTokens: 7, + cacheWriteTokens: 2, + reasoningTokens: 3, + } + startStep(session, 1, 1) + const source = usageChunk(session, usage, 1, 1) + finalUsage(session, usage, 1, 1, [source]) + + expect(projected(ctx, session)).toEqual({ + uncachedInputTokens: 10, + outputTokens: 4, + cacheReadTokens: 7, + cacheWriteTokens: 2, + }) + expect(changes).toHaveLength(1) + }) + + it('replaces an earlier same-step chunk sample with the final usage', async () => { + const { ctx, session } = await harness() + startStep(session, 1, 1) + const source = usageChunk(session, { + inputTokens: 10, + outputTokens: 2, + cacheReadTokens: 3, + }, 1, 1) + finalUsage(session, { + inputTokens: 14, + outputTokens: 5, + cacheReadTokens: 8, + cacheWriteTokens: 1, + }, 1, 1, [source]) + + expect(projected(ctx, session)).toEqual({ + uncachedInputTokens: 14, + outputTokens: 5, + cacheReadTokens: 8, + cacheWriteTokens: 1, + }) + }) + + it('accumulates disjoint buckets across steps without adding reasoning twice', async () => { + const { ctx, session } = await harness() + startStep(session, 1, 1) + const first = usageChunk(session, { + inputTokens: 10, + outputTokens: 6, + reasoningTokens: 5, + cacheReadTokens: 2, + }, 1, 1) + finalUsage(session, { + inputTokens: 10, + outputTokens: 6, + reasoningTokens: 5, + cacheReadTokens: 2, + }, 1, 1, [first]) + startStep(session, 1, 2) + const second = usageChunk(session, { + inputTokens: 20, + outputTokens: 9, + reasoningTokens: 7, + cacheWriteTokens: 4, + }, 1, 2) + finalUsage(session, { + inputTokens: 20, + outputTokens: 9, + reasoningTokens: 7, + cacheWriteTokens: 4, + }, 1, 2, [second]) + + expect(projected(ctx, session)).toEqual({ + uncachedInputTokens: 30, + outputTokens: 15, + cacheReadTokens: 2, + cacheWriteTokens: 4, + }) + }) + + it('retains a usage chunk when the request produces no final assistant message', async () => { + const { ctx, session } = await harness() + startStep(session, 1, 1) + usageChunk(session, { inputTokens: 9, outputTokens: 1 }, 1, 1) + session.append('step/end', { turn: 1, step: 1 }) + expect(projected(ctx, session)).toEqual({ + uncachedInputTokens: 9, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }) + }) + + it('does not erase historical billing when the visible surface is replaced', async () => { + const { ctx, session } = await harness() + startStep(session, 1, 1) + const source = usageChunk(session, { inputTokens: 12, outputTokens: 3 }, 1, 1) + finalUsage(session, { inputTokens: 12, outputTokens: 3 }, 1, 1, [source]) + const before = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'before compaction' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'compacted' }], + source: { kind: 'plugin', plugin: 'test' }, + }), { + surfaceOp: { op: 'replace', start: before.seq, end: before.seq }, + sourceEventSeqs: [before.seq], + }) + + expect(projected(ctx, session)).toEqual({ + uncachedInputTokens: 12, + outputTokens: 3, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }) + }) + + it('unregisters with the token-meter fiber and restores from a JSON checkpoint', async () => { + const { ctx, session, meterFiber } = await harness() + startStep(session, 1, 1) + usageChunk(session, { inputTokens: 8, outputTokens: 2, cacheReadTokens: 5 }, 1, 1) + const checkpoint = JSON.parse(JSON.stringify( + ctx.sessionProjections.checkpoint(session), + )) as ReturnType + + await meterFiber.dispose() + expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('tokenUsage') + + await ctx.plugin(TokenMeterService) + expect(ctx.sessionProjections.viewCheckpoint(checkpoint).tokenUsage).toEqual({ + uncachedInputTokens: 8, + outputTokens: 2, + cacheReadTokens: 5, + cacheWriteTokens: 0, + }) + }) +}) diff --git a/packages/llm/token-meter/tsconfig.json b/packages/llm/token-meter/tsconfig.json index 481fad6e15..92081a860b 100644 --- a/packages/llm/token-meter/tsconfig.json +++ b/packages/llm/token-meter/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-projection/session-projection" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66ea5bea26..919576a360 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3144,6 +3144,9 @@ importers: schemastery: specifier: ^3.18.0 version: 3.18.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -3154,6 +3157,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) From bf618dabf96d9e2f3037fdc264a308100d7fd8c0 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 29 Jul 2026 15:27:59 +0800 Subject: [PATCH 18/47] refactor(web): project usage and snapshot request context --- ...26-07-28-host-owned-web-session-metrics.md | 39 ---- ...07-28-host-owned-web-session-metrics.zh.md | 39 ---- ...token-usage-and-request-context.i18n.yaml} | 6 +- ...ojected-token-usage-and-request-context.md | 45 +++++ ...cted-token-usage-and-request-context.zh.md | 45 +++++ apps/web/tests/question-composer.e2e.ts | 2 +- .../snapshots/code-mode-round/ui.expected.md | 3 +- .../cordis-tool-round/ui.expected.md | 3 +- .../lifecycle-chrome/reloaded.expected.md | 3 +- .../live-interactions/cancel.expected.md | 3 +- .../live-interactions/error-auth.expected.md | 2 + .../live-interactions/retry.expected.md | 3 +- .../question-composer/answered.expected.md | 3 +- .../snapshots/seeded-history/ui.expected.md | 29 ++- .../snapshots/steering/mid-steer.expected.md | 5 +- .../snapshots/steering/settled.expected.md | 3 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 117 +++++++++++- .../client/connection/src/client/index.ts | 2 +- .../client/connection/tests/fixture.spec.ts | 34 +++- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/sessions/conversation.ts | 16 +- .../runtime/src/client/sessions/manager.ts | 27 +-- .../runtime/src/client/sessions/session.ts | 77 +++----- .../client/runtime/tests/client-apply.spec.ts | 30 +-- packages/client/runtime/tests/fake-api.ts | 3 +- packages/client/runtime/tests/manager.spec.ts | 69 +++---- packages/client/runtime/tests/session.spec.ts | 162 ++++++---------- packages/client/test-runtime/src/fixtures.ts | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-conversation/package.json | 2 + .../src/client/chat/ChatView.tsx | 6 +- .../src/client/chat/StatsLine.tsx | 74 ++++---- .../tests/chat-branch-tails.spec.tsx | 20 +- .../tests/chat-code-subcalls.spec.tsx | 2 +- .../tests/chat-stats-bash-sample.spec.tsx | 174 ++++++++++++------ .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 24 +-- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/queue-dock.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- packages/client/ui-conversation/tsconfig.json | 3 + packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 93 +++------- .../host/apiproxy/src/api/events.schema.ts | 4 +- packages/host/apiproxy/src/api/events.ts | 28 +-- packages/host/apiproxy/src/api/index.ts | 6 +- .../host/apiproxy/src/api/sessions.schema.ts | 17 +- packages/host/apiproxy/src/api/sessions.ts | 30 +-- packages/host/apiproxy/src/session-metrics.ts | 122 ------------ .../tests/api-proxy-model-request.spec.ts | 30 ++- .../host/apiproxy/tests/rpc-schemas.spec.ts | 38 +--- .../apiproxy/tests/session-metrics.spec.ts | 143 -------------- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 6 + packages/llm/token-meter/README.zh.md | 6 + packages/llm/token-meter/package.json | 4 +- pnpm-lock.yaml | 3 + 78 files changed, 748 insertions(+), 934 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md delete mode 100644 .agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md rename .agents/notes/implemented/architecture/{2026-07-28-host-owned-web-session-metrics.i18n.yaml => 2026-07-29-projected-token-usage-and-request-context.i18n.yaml} (52%) create mode 100644 .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md create mode 100644 .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md delete mode 100644 packages/host/apiproxy/src/session-metrics.ts delete mode 100644 packages/host/apiproxy/tests/session-metrics.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md deleted file mode 100644 index f0a0bdb8ea..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Host-owned Web session metrics - -Status: implemented - -English | [中文](2026-07-28-host-owned-web-session-metrics.zh.md) - -## Problem - -A Web stats line derived from the currently loaded conversation nodes is window-dependent under pagination. Compaction can replace visible content without preserving historical usage, while the selected model does not prove that a request used its route or capacity. Cache-write tokens also risk being folded into a cache-hit formula whose denominator has different semantics. - -## Decision - -The Host owns one session-level metrics projection. It incrementally folds the complete durable event log, keys settled usage by `(turn, step)`, and replaces an earlier usage record for the same key instead of double-counting chunk and message forms. Uncached input, output, cache reads, and cache writes remain four disjoint cumulative buckets. Compaction can change the current prompt surface without erasing historical usage. - -Current context pressure is the point-in-time `tokenMeter.measure(session).totalTokens`. Capacity instead belongs to the latest model request attempt observed by the current live mux connection. `LlmService.prepareCall()` retains the context metadata obtained by the exact lookup that also validates reasoning/defaults. After the final provider/model is fixed and the outer `llm/stream` call returns a handle, the loop publishes one contained `agent/model-request` notification. This boundary observes an attempt, not proof of provider I/O: preparation or a synchronous outer waterfall failure emits nothing, while short-circuit handles and later lazy adapter construction, iteration failure, or abort still count. - -The tail `session.history` response carries durable usage and pressure, while older pages omit them. Live changes use `session/metrics` mux frames. Both forms carry a durable-log revision and a projection revision; the client accepts only nondecreasing revisions and preserves metrics across older-page prepend. - -ApiProxy forwards each notification as a distinct `session/model-request` frame only to mux connections already open when dispatch occurs. It never places the frame in `session.history` or a subscription baseline. The client keeps durable `metrics` and transient `modelRequestContextWindow` as separate snapshot fields, replaces or explicitly clears the capacity on the next observed request, and clears both fields on `session/subscribed`; reconnect, restore, and a new subscription therefore start unknown until another request is observed. - -The Web stats line joins the durable projection and live capacity only at presentation. It renders uncached input, output, and cache reads separately, computes cache hit as `cacheRead / (uncachedInput + cacheRead)`, and shows current context as a percentage only when the current connection observed a capacity. Cache writes never enter that percentage. Visible nodes continue to supply only turn and step counts. - -## Alternatives considered - -**Fold the loaded node window in React.** This cannot survive pagination or compaction and duplicates durable-log semantics in a presentation package. - -**Send usage only with raw assistant events.** Reconnect and older-page stitching would still need the client to reconstruct a full-log aggregate, and duplicate usage forms would need protocol-specific repair there. - -**Reuse one total-token field for cache hit.** Cache reads, cache writes, and uncached input represent distinct provider accounting buckets; combining them would make the displayed rate misleading. - -**Query the selected route before dispatch.** Selection may never produce a request, and a second metadata lookup can race the registration-bound lookup that actually validates and dispatches the call. - -**Persist or replay the latest request capacity.** That would make a former request look current on reconnect or restore even though the new connection observed no request. The denominator is deliberately live and opportunistic. - -## Consequences - -Token totals remain stable across pagination, replay, compaction, and browser reconnect. The client stores a small detached durable projection plus one connection-local denominator instead of scanning the conversation window, and the status row remains readable for large histories through compact number formatting. - -The Host performs one incremental log fold per session and schedules durable projection updates only for usage, request-header, or surface-changing events; text and reasoning deltas do not publish metrics. A new connection omits the percentage until it observes a request with context metadata. A later request without metadata clears the denominator, while deployments without a token meter still retain the durable counters and label context unavailable instead of fabricating pressure. diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md b/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md deleted file mode 100644 index 4535fd8a4b..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Host 拥有的 Web 会话指标 - -Status: implemented - -[English](2026-07-28-host-owned-web-session-metrics.md) | 中文 - -## 问题 - -Web 统计行若根据当前加载的会话节点推导指标,其结果会随分页窗口变化。压缩(compaction)可以替换可见内容,却无法保留历史用量;所选模型也不能证明某次请求实际采用了该模型的路由或容量。缓存写入 token 还可能被计入缓存命中率公式,而该公式的分母具有不同语义。 - -## 决策 - -Host 拥有一项会话级指标投影。它以增量方式归并完整的持久事件日志,按 `(turn, step)` 标识已结算用量;同一标识再次出现时,会替换较早的用量记录,而不会重复统计分片和消息两种形态。未缓存输入、输出、缓存读取与缓存写入保持为四个彼此独立的累计计数项。压缩可以改变当前提示词表层,但不会抹除历史用量。 - -当前上下文压力是即时的 `tokenMeter.measure(session).totalTokens`。容量则属于当前实时 mux 连接观察到的最新模型请求尝试。`LlmService.prepareCall()` 会保留同一次精确查询取得的上下文元数据,该查询也负责校验推理设置与默认值。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发布一条 `agent/model-request` 通知,并收容该通知的失败。这个边界观察到的是一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄以及之后的惰性适配器构造、迭代失败或中止仍会计入。 - -`session.history` 尾页响应携带持久用量与压力,较早页面则省略这两项。实时变更使用 `session/metrics` mux 帧。两种形式都携带持久日志修订号和投影修订号;客户端只接受不减小的修订号,并在向前加载较早页面时保留指标。 - -ApiProxy 只把每条通知作为独立的 `session/model-request` 帧转发给分派发生时已经打开的 mux 连接。它绝不会把该帧放入 `session.history` 或订阅基线。客户端把持久 `metrics` 与临时 `modelRequestContextWindow` 保存在彼此独立的快照字段中,在观察到下一次请求时替换或显式清除容量,并在收到 `session/subscribed` 时清除这两个字段;因此,重连、恢复和新订阅都会从未知容量开始,直到观察到另一次请求。 - -Web 统计行只在展示时结合持久投影与实时容量。它分别呈现未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率,并且只有当前连接观察到容量时,才把当前上下文显示为该容量的百分比。缓存写入绝不计入缓存命中率。可见节点仍然只提供轮次和步骤计数。 - -## 备选方案 - -**在 React 中归并已加载的节点窗口。** 此方案无法跨越分页或压缩保留数据,还会在展示包中重复实现持久日志语义。 - -**只随原始 assistant 事件发送用量。** 重连和较早页面拼接仍会要求客户端重建完整日志聚合,而且重复的用量形态需要在客户端按协议专门修复。 - -**为缓存命中率复用单一的 token 总数字段。** 缓存读取、缓存写入与未缓存输入是提供方记账中的不同计数项;将它们合并会使显示的比率产生误导。 - -**在分派前查询所选路由。** 选择操作可能永远不会产生请求;第二次元数据查询还可能与实际校验并分派调用的、绑定注册项的查询发生竞态。 - -**持久化或回放最新请求的容量。** 即使新连接没有观察到任何请求,这也会让先前请求在重连或恢复后显得仍然有效。该分母刻意只采用实时且恰好可得的数据。 - -## 后果 - -token 总量在分页、回放、压缩和浏览器重连期间保持稳定。客户端存储一项小型、脱耦的持久投影与一个连接本地分母,无需扫描会话窗口;状态行采用紧凑数字格式,因此在较长的历史记录中仍然清晰易读。 - -Host 为每个会话执行一次增量日志归并,仅为用量事件、请求头事件或表层变更事件调度持久投影更新;文本与推理(reasoning)增量不会发布指标。新连接在观察到带上下文元数据的请求之前不会显示百分比。后续不带元数据的请求会清除该分母;未部署 token 计量器时,系统仍保留持久计数器,并把上下文标示为不可用,而不会虚构压力值。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml similarity index 52% rename from .agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml index 8c72912377..6aa9498b9c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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/architecture/2026-07-28-host-owned-web-session-metrics.md -2026-07-28-host-owned-web-session-metrics.md: f0a0bdb8ea4c1ba1f983d016c3cfde9c67a0424d -2026-07-28-host-owned-web-session-metrics.zh.md: 4535fd8a4b760aa031c11ab766b6be583b9b740d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md +2026-07-29-projected-token-usage-and-request-context.md: 3ac8c29f7752828f2d833359293c7b1c513d75ca +2026-07-29-projected-token-usage-and-request-context.zh.md: 02b03d1516a39b7727b214b63a9039623b69c56b diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md new file mode 100644 index 0000000000..3ac8c29f77 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md @@ -0,0 +1,45 @@ +# Agent Note: Projected token usage and request context + +Status: implemented + +English | [中文](2026-07-29-projected-token-usage-and-request-context.zh.md) + +## Problem + +A Web stats line derived from the currently loaded conversation nodes is window-dependent under pagination. Compaction can replace visible content without preserving historical usage. Conversely, context occupancy describes one real request boundary: a selected model is only an intention, and combining token pressure from one moment with capacity resolved for another route creates a false percentage. + +These two values therefore have different lifetimes. Provider-reported billing is durable, replayable session state. Request pressure and registration-bound capacity are an opportunistic live observation that must disappear across a connection generation. + +## Decision + +`@deepseek-ai/dsh-token-meter` registers the generic `tokenUsage` session projection when `ctx.sessionProjections` is present. The projection folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An `assistant/chunk` usage sample survives a later failed request; an `assistant/message` usage value replaces the earlier value for the same `(turn, step)` instead of being counted twice. Reasoning tokens remain an output subdivision and are not added again. Compaction and surface replacement do not erase earlier billing. + +The projection uses the standard projection lifecycle and wire path. History tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload all remain generic. There is no token-specific history field, mux frame, projector, revision counter, or client fence. + +`LlmService.prepareCall()` retains context metadata from the exact lookup that also validates reasoning and captures the adapter registration. After the outer stream call returns its handle and before iteration begins, AgentLoop emits one contained `agent/model-request` notification. Preparation or a synchronous outer waterfall failure emits nothing; short-circuit handles and later iterator construction, iteration, or abort failures still count as an observed request attempt. + +ApiProxy handles that notification synchronously. It reads `tokenMeter.measure(agent.session).totalTokens` once when the optional service is present and combines the result with the same prepared call's registration-bound `contextWindow`. It broadcasts one atomic `session/model-request` frame containing the route, turn, step, and whichever of `contextTokens` and `contextWindow` are available. Measurement failure omits only the numerator. The frame goes only to mux connections already open at that instant; history, subscription baselines, reconnect, and restore never replay it. + +The client stores the complete latest request frame as `ConversationSnapshot.modelRequest`. Every later frame replaces the entire snapshot, so omitted fields clear earlier values. `SessionManager` temporarily holds a pre-instantiation frame, while a new subscription generation, disconnect, or session removal clears both resident and pending values. Model selection alone does not change this snapshot. + +The Web `StatsLine` reads `tokenUsage` through the standard `useProjection` hook and reads request telemetry plus visible nodes through `useSession`. It renders uncached input, output, and cache reads separately, computes cache hit as `cacheRead / (uncachedInput + cacheRead)`, and shows context occupancy only when one request snapshot contains both numerator and capacity. Visible nodes continue to supply only turn and step counts. The existing inline text UI is retained; the model selector gains no circle or other accessory. + +## Alternatives considered + +**A custom session metrics history field and mux frame.** This duplicated the generic projection protocol, cache, recovery, and seq fencing while coupling durable billing to transient request pressure. + +**Fold the loaded node window in React.** This cannot survive pagination or compaction and makes a presentation package reconstruct log semantics. + +**Publish usage only with final assistant messages.** A request that reports a usage chunk and then fails would lose provider billing. + +**Query capacity from the selected model.** Selection may never produce a request, and a second metadata lookup can disagree with the registration-bound lookup used by the actual call. + +**Persist or replay the latest request snapshot.** A request from a prior connection would appear current after restore even though no new request was observed. + +**Add a context circle beside the model selector.** That placement suggests selected-model state. The existing stats line expresses the request-scoped semantics without introducing a duplicate UI or data path. + +## Consequences + +Token totals stay stable across pagination, compaction, replay, and reconnect because they are ordinary durable projection state. Context occupancy is deliberately unknown after reconnect until a new real request is observed. Deployments without token-meter or without model capacity still publish the request route and clear stale optional fields instead of fabricating a percentage. + +ApiProxy performs one synchronous optional measurement and one frame conversion per observed request. It owns no per-session metrics cache or refresh queue. The browser keeps one generic projection value plus one small connection-local request snapshot, and streaming text deltas do not force the stats line to recompute. diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md new file mode 100644 index 0000000000..02b03d1516 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -0,0 +1,45 @@ +# Agent Note:token 用量投影与请求上下文 + +Status: implemented + +[English](2026-07-29-projected-token-usage-and-request-context.md) | 中文 + +## 问题 + +Web 统计行若根据当前已加载的会话节点推导,其结果会在分页时依赖当前窗口。压缩(compaction)可以替换可见内容,却不保留历史用量。另一方面,上下文占用率描述的是一个真实请求边界:所选模型只代表意图;若把某一时刻的 token 压力与另一路由解析出的容量组合,就会产生虚假百分比。 + +因此,这两个值具有不同的生命周期。提供方报告的计费用量属于持久、可回放的会话状态。请求压力和与注册项绑定的容量则是恰好可得的实时观测,跨连接代次时必须消失。 + +## 决策 + +当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册通用的 `tokenUsage` 会话投影。该投影将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;同一 `(turn, step)` 的 `assistant/message` 用量值会替换先前值,不会重复计数。推理(reasoning)token 仍是输出的细分项,不会再次累加。压缩和表层替换不会抹除先前的计费用量。 + +该投影使用标准的投影生命周期与协议路径。历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载均保持通用机制。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端 seq 防护机制。 + +`LlmService.prepareCall()` 会保留精确查询得到的上下文元数据;同一次查询还会校验推理强度并捕获适配器注册项。外层流调用返回句柄后、开始迭代前,AgentLoop 会发出一条失败受收容的 `agent/model-request` 通知。准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知;短路句柄以及之后的迭代器构造失败、迭代失败或中止仍算作一次已观测的请求尝试。 + +ApiProxy 会同步处理该通知。当可选服务存在时,它会读取一次 `tokenMeter.measure(agent.session).totalTokens`,并将结果与同一准备完成调用中绑定注册项的 `contextWindow` 合并。它会广播一个原子 `session/model-request` 帧,其中包含路由、轮次、步骤,以及可用的 `contextTokens`/`contextWindow` 字段。测量失败时只省略分子。该帧只发送给当时已经打开的 mux 连接;历史记录、订阅基线、重连和恢复都绝不回放该帧。 + +客户端将最新的完整请求帧存储为 `ConversationSnapshot.modelRequest`。每个后续帧都会替换整个快照,因此省略字段会清除先前值。`SessionManager` 会临时保存一个实例化前帧;新订阅代次、断开连接或移除会话时,则会同时清除常驻值和待处理值。仅选择模型不会改变该快照。 + +Web `StatsLine` 通过标准 `useProjection` 钩子读取 `tokenUsage`,并通过 `useSession` 读取请求观测数据与可见节点。它分别显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率,并且只有同一份请求快照同时包含分子与容量时才显示上下文占用率。可见节点仍只提供轮次和步骤计数。系统保留现有的行内文本 UI;模型选择器不增加圆环或其他附属控件。 + +## 备选方案 + +**自定义会话指标历史字段和 mux 帧。** 这会重复实现通用投影协议、缓存、恢复和 seq 防护机制,并将持久计费用量与临时请求压力耦合。 + +**在 React 中归并已加载的节点窗口。** 此方案无法跨分页或压缩保留数据,还会迫使展示包重建日志语义。 + +**仅随最终 assistant 消息发布用量。** 如果请求报告一个用量分片后失败,就会丢失提供方计费用量。 + +**从所选模型查询容量。** 选择操作可能永远不会产生请求;第二次元数据查询还可能与实际调用所用的、绑定注册项的查询不一致。 + +**持久化或回放最新请求快照。** 即使没有观察到任何新请求,先前连接的请求也会在恢复后显得仍是当前请求。 + +**在模型选择器旁增加上下文圆环。** 该位置会让人以为这是所选模型的状态。现有统计行可以表达按请求作用域的语义,无需引入重复的 UI 或数据路径。 + +## 后果 + +token 总量在分页、压缩、回放和重连期间保持稳定,因为它们属于普通的持久投影状态。重连后,上下文占用率会刻意保持未知,直到系统观察到新的真实请求。未部署 token-meter 或模型不提供容量时,系统仍会发布请求路由,并清除陈旧的可选字段,而不会虚构百分比。 + +ApiProxy 会为每次已观测请求执行一次可选的同步测量和一次帧转换。它不拥有任何逐会话指标缓存或刷新队列。浏览器只保留一个通用投影值和一个小型连接本地请求快照;流式文本增量不会迫使统计行重新计算。 diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 6ecdb683b3..8689a74fc2 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -104,7 +104,7 @@ describe('web e2e: resident question composer round trip', () => { const inner = child.getBoundingClientRect() return Math.max(box.top - inner.top, inner.bottom - box.bottom) }))) - const list = rows[0]?.parentElement ?? null + const list = card.querySelector('[data-question-scroll]') return { rows: rows.length, spill: Math.max(...spill), diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 4847924619..dccd3fd7dc 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -12,6 +12,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - 'button "Think The user wants me to write a single `run_code` program that:"': - img - img @@ -28,7 +29,7 @@ - img - text: Think The program ran successfully. Let me now reply DONE as instructed. - paragraph: DONE -- text: cache hit 52% · 17,490 tokens · 1 turns · 2 steps +- text: 8.3k uncached input · 252 output · 9k cache read · cache hit 52% · context 7% of 128k · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index e5e5626be3..b9c1ea7b2d 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -12,6 +12,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to:": - img - img @@ -42,7 +43,7 @@ - img - text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop. - paragraph: CORDIS_UI_DONE -- text: cache hit 77% · 66,813 tokens · 1 turns · 4 steps +- text: 15.3k uncached input · 312 output · 51.2k cache read · cache hit 77% · context 13% of 128k · 1 turns · 4 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 33d1f7e6bf..1a0b740ce6 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -12,12 +12,13 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to reply with a single word. Let me comply.": - img - img - text: Think The user wants me to reply with a single word. Let me comply. - paragraph: LIGHTHOUSE -- text: cache hit 99% · 7,810 tokens · 1 turns · 1 steps +- text: 109 uncached input · 21 output · 7.7k cache read · cache hit 99% · context unknown · 1 turns · 1 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 3d092b17ec..bf87981ea2 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -12,8 +12,9 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - paragraph: partial -- text: 已停止 0 tokens · 1 turns · 1 steps +- text: 已停止 0 uncached input · 0 output · 0 cache read · context 4% of 128k · 1 turns · 1 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 5272bcf2d1..9fe1195192 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -12,6 +12,8 @@ - img - button "编辑": - img +- button "▸ 上下文注入" +- text: 0 uncached input · 0 output · 0 cache read · context 4% of 128k · 0 turns · 0 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 5935872557..6ad6d27487 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -12,12 +12,13 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. -- text: cache hit 99% · 7,869 tokens · 1 turns · 1 steps +- text: 110 uncached input · 79 output · 7.7k cache read · cache hit 99% · context 4% of 128k · 1 turns · 1 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 91ff2cdf88..a243c38c48 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -12,6 +12,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img @@ -25,7 +26,7 @@ - img - text: Think The user answered "Blue". I should now reply with the single word DONE and stop. - paragraph: DONE -- text: cache hit 95% · 8,769 tokens · 1 turns · 2 steps +- text: 397 uncached input · 180 output · 8.2k cache read · cache hit 95% · context 4% of 128k · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 4f9181f702..ac9c92dbfd 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -1,32 +1,41 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop." +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": + - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- button: - - img -- text: Read a.txt -- button: - - img -- text: Read b.txt +- img +- text: Read +- button "a.txt" +- img +- text: Read +- button "b.txt" - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": + - img - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. - paragraph: DONE -- text: cache hit 98% · 15,962 tokens · 1 turns · 2 steps +- text: 339 uncached input · 135 output · 15.5k cache read · cache hit 98% · context unknown · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] - button "选择模型,当前 deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 8d33ea6283..bbfae558c9 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -12,6 +12,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -19,9 +20,7 @@ - button: - img - img -- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)" -- button "▸ 问题内容" -- text: cache hit 98% · 7,946 tokens · 1 turns · 1 steps +- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 151 uncached input · 115 output · 7.7k cache read · cache hit 98% · context 4% of 128k · 1 turns · 1 steps" - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index f08fc518e8..f6ed68511a 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -12,6 +12,7 @@ - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.": - img - img @@ -25,7 +26,7 @@ - img - text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer. - paragraph: Great, let's move forward. BANANA! -- text: cache hit 98% · 15,967 tokens · 1 turns · 2 steps +- text: 323 uncached input · 156 output · 15.5k cache read · cache hit 98% · context 6% of 128k · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c0543e4ca..1026e1478b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1548,7 +1548,7 @@ Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/ti export type TokenMeterConfig = Record ``` -Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts) +Source: [`packages/llm/token-meter/src/types.ts:12`](../packages/llm/token-meter/src/types.ts) ## `@deepseek-ai/dsh-tool-bash` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 31574e0eb4..3ab05fc0a1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2031,7 +2031,7 @@ estimateMessage(message: Message): number Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md) -Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:85`](../../packages/llm/token-meter/src/index.ts) ## `ctx.toolResultPrune` — `ToolResultPruneService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1c36786cce..391059ec2a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,7 +9,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:228`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:228`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:237`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:424`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 05ad4325ff..a773269257 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -12,7 +12,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, SessionProjectionsBlock, + ModelReasoningEffort, ModelRequestTelemetry, ModelTarget, SessionModels, SessionProjectionsBlock, GoalsApi, GoalRef, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cab616aabd..0843341191 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -15,6 +15,7 @@ import type { AssistantMessage, ContentBlock, MessageSource, + TokenUsage, ToolResultMessage, UserMessage, } from '@deepseek-ai/dsh-llm' @@ -103,6 +104,16 @@ function sid(id: string): SessionId { return id as SessionId } +/** Deterministic provider billing attached to fixture assistant messages. */ +function fixtureUsage(turn: number, step: number): TokenUsage { + return { + inputTokens: 20 + turn % 5, + outputTokens: 8 + step, + cacheReadTokens: turn === 0 ? 0 : 80, + cacheWriteTokens: turn % 10 === 0 ? 4 : 0, + } +} + /** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50), * mixing reasoning blocks / tool call+result / steering / context. */ function buildAlphaLog(): SessionEvent[] { @@ -110,7 +121,17 @@ function buildAlphaLog(): SessionEvent[] { let time = Date.now() - 3_600_000 const push = (e: Record): number => { const seq = events.length - events.push({ seq, time: (time += 800), ...e }) + const data = e['data'] as Record | undefined + const authored = e['type'] === 'assistant/message' && data !== undefined + ? { + ...e, + data: { + ...data, + usage: fixtureUsage(data['turn'] as number, data['step'] as number), + }, + } + : e + events.push({ seq, time: (time += 800), ...authored }) return seq } for (let turn = 0; turn < 60; turn++) { @@ -369,6 +390,60 @@ function permissionSelectOf( } } +interface FixtureTokenUsageProjection { + uncachedInputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +/** Fixture parallel of token-meter's last-sample-replacing usage projection. */ +function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection { + const totals: FixtureTokenUsageProjection = { + uncachedInputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } + let last: { + turn: number + step: number + buckets: FixtureTokenUsageProjection + } | null = null + for (const event of log) { + const item = event as unknown as { + type: string + data: { + turn?: number + step?: number + usage?: TokenUsage + chunk?: { type?: string; usage?: TokenUsage } + } + } + const usage = item.type === 'assistant/chunk' && item.data.chunk?.type === 'usage' + ? item.data.chunk.usage + : item.type === 'assistant/message' + ? item.data.usage + : undefined + if (usage === undefined || item.data.turn === undefined || item.data.step === undefined) continue + const buckets: FixtureTokenUsageProjection = { + uncachedInputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheReadTokens: usage.cacheReadTokens ?? 0, + cacheWriteTokens: usage.cacheWriteTokens ?? 0, + } + const previous = last?.turn === item.data.turn && last.step === item.data.step + ? last.buckets + : undefined + totals.uncachedInputTokens += buckets.uncachedInputTokens - (previous?.uncachedInputTokens ?? 0) + totals.outputTokens += buckets.outputTokens - (previous?.outputTokens ?? 0) + totals.cacheReadTokens += buckets.cacheReadTokens - (previous?.cacheReadTokens ?? 0) + totals.cacheWriteTokens += buckets.cacheWriteTokens - (previous?.cacheWriteTokens ?? 0) + last = { turn: item.data.turn, step: item.data.step, buckets } + } + return totals +} + function projectionValuesOf(log: readonly SessionEvent[]): Record { const values: Record = {} const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title') @@ -383,12 +458,28 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record[] { const type = (event as { type: string }).type + if ( + (type === 'assistant/chunk' + && (event as unknown as { data: { chunk?: { type?: string } } }).data.chunk?.type === 'usage') + || (type === 'assistant/message' + && (event as unknown as { data: { usage?: TokenUsage } }).data.usage !== undefined) + ) { + return [{ + type: 'session/projection', + sessionId: id, + key: 'tokenUsage', + value: tokenUsageOf(log), + seq: event.seq, + }] + } if (type === 'session/title') { const values = projectionValuesOf(log) /* v8 ignore next -- the advancing title event is in the log, so the key is present. */ @@ -853,7 +944,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { replays.delete(id) const done = pieces.slice(0, i).join('') append(id, { type: 'assistant/chunk', data: { turn, step, chunk: { type: 'block-end', index: 0, block: { type: 'text', text: done } } } }) - append(id, { type: 'assistant/message', surfaceOp: 'append', data: { turn, step, message: assistantMessage(text(aborted ? `${done}(已中断)` : done)) } }) + append(id, { + type: 'assistant/message', + surfaceOp: 'append', + data: { + turn, + step, + message: assistantMessage(text(aborted ? `${done}(已中断)` : done)), + usage: fixtureUsage(turn, step), + }, + }) append(id, { type: 'step/end', data: { turn, step } }) append(id, { type: 'turn/end', data: { turn, reason: { kind: aborted ? 'cancelled' : 'completed' } } }) setRunning(id, false) @@ -1036,6 +1136,19 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { append(id, { type: 'plan/mode', data: { active: plan.wanted } }) } append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) }) + const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } + const usage = tokenUsageOf(logOf(id)) + emitMux({ + type: 'session/model-request', + sessionId: id, + turn, + step: 0, + provider: target.provider, + model: target.model, + contextTokens: usage.uncachedInputTokens + usage.outputTokens + + usage.cacheReadTokens + usage.cacheWriteTokens, + contextWindow: 128_000, + }) startReply( id, turn, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 29cb02c6fa..4097a23036 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -17,7 +17,7 @@ export type { ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, SessionProjectionsBlock, + ModelReasoningEffort, ModelRequestTelemetry, ModelTarget, SessionModels, SessionProjectionsBlock, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 09a3efecd3..d25a4974ca 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -84,6 +84,12 @@ describe('createFixtureApi', () => { }, plan: { active: false, pending: false }, goal: null, + tokenUsage: { + uncachedInputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, } }, }) }) @@ -188,6 +194,20 @@ describe('createFixtureApi', () => { expect(types).toContain('assistant/chunk') expect(types).toContain('assistant/message') expect(types.at(-1)).toBe('turn/end') + expect(frames).toContainEqual({ + type: 'session/model-request', + sessionId: id, + turn: 0, + step: 0, + provider: 'deepseek', + model: 'deepseek-v4-flash', + contextTokens: 0, + contextWindow: 128_000, + }) + expect(frames.some(frame => + frame.type === 'session/projection' + && frame.key === 'tokenUsage' + && (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true) const finalize = frames.find((f): f is Extract => f.type === 'session/event' && f.event.type === 'assistant/message') expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)') // Idle cancel: no replay in flight, must not explode; running flips false. @@ -219,7 +239,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 8) abort.abort() + if (envelopes.length >= 9) abort.abort() } return envelopes } @@ -227,16 +247,18 @@ describe('createFixtureApi', () => { const second = await openOnce() expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow the subscribed frame (title + todos + permissions + plan + goal units). + // Projection baseline frames follow subscribed (domain units + token usage). expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' }) expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) - expect(first[6]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[6]?.rpcId).toBe(first[6]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[7]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[7]?.rpcId).toBe(first[7]?.rpcId) + expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' }) + expect(first[7]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[7]?.rpcId).toBe(first[7]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[8]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[8]?.rpcId).toBe(first[8]?.rpcId) + expect(first.some(envelope => envelope.payload.type === 'session/model-request')).toBe(false) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index e74dacded2..9f33a7e7ae 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.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 packages/client/runtime/README.md -README.md: 879730733f3d89c3962d8c54bcfd53795a980049 -README.zh.md: 1b1d7f03d1f5f03c054dfeaa790a9f6f91e0dca2 +README.md: ba9a7d455e8a193f23884411eb1928a10f21ddd0 +README.zh.md: 873fefca48585efed010589917f2c63653b08e5b diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 879730733f..ba9a7d455e 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos` and `title`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. Durable `ConversationSnapshot.metrics` instead comes from the separate history-tail value and live `session/metrics` frames because point-in-time token-meter pressure can advance at the same durable log revision; only nondecreasing log and projection revisions are accepted. `ConversationSnapshot.modelRequestContextWindow` separately retains capacity from the latest `session/model-request` observed on the current mux connection. A later request replaces or clears that value, while `session/subscribed` clears both metrics ordering and capacity; reconnect, restore, and a new subscription therefore show no percentage until another request is observed. Missing metrics remain `null` rather than being inferred from the visible node window. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`, `title`, and `tokenUsage`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. `ConversationSnapshot.modelRequest` separately retains the complete latest `session/model-request` observed on the current mux connection. Each frame replaces the whole snapshot, so omitted numerator or capacity fields clear an earlier value. `SessionManager` buffers one pre-instantiation snapshot, while `session/subscribed`, disconnect, and removal clear resident and pending values; reconnect, restore, and a new subscription therefore show no context percentage until another request is observed. Model selection alone does not alter request telemetry. ## Workspace and Session lists diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 1b1d7f03d1..873fefca48 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos` 与 `title`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。持久的 `ConversationSnapshot.metrics` 则来自独立的 history 尾页值与实时 `session/metrics` 帧,因为即时 token-meter 压力可以在相同持久日志修订号上继续变化;客户端只接受日志修订号与投影修订号均不减小的数据。`ConversationSnapshot.modelRequestContextWindow` 另行保留当前 mux 连接观察到的最新 `session/model-request` 容量。后续请求会替换或清除该值,`session/subscribed` 则同时清除指标顺序状态与容量;因此,重连、恢复和新订阅都不会显示百分比,直到观察到另一次请求。缺失的 metrics 保持为 `null`,而不是根据可见节点窗口推断。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`、`title` 与 `tokenUsage`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。`ConversationSnapshot.modelRequest` 另行保留当前 mux 连接观察到的最新完整 `session/model-request`。每个帧都会替换整个快照,因此分子或容量字段一旦缺失,就会清除先前值。`SessionManager` 会缓冲一个实例化前快照;`session/subscribed`、断开连接和移除会话则会清除常驻值与待处理值;因此,重连、恢复和新订阅都不会显示上下文百分比,直到观察到另一次请求。仅选择模型不会改变请求观测数据。 ## Workspace 与 Session 列表 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 78ec9e2cd8..f211b209fd 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -6,7 +6,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { - RpcError, SessionId, SessionMetrics, ToolCallView, ToolResultView, + ModelRequestTelemetry, RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' @@ -267,16 +267,6 @@ export interface ConversationSnapshot { */ blank: boolean lastAgentError: string | null - /** - * Host-owned cumulative usage/current pressure. Independent of `nodes` - * pagination; null until a tail response or live metrics frame supplies a - * current durable value. - */ - metrics: SessionMetrics | null - /** - * Capacity from the latest model-request attempt observed on this mux - * generation. Absent before the first such request, after a request whose - * registration exposes no capacity, and after `session/subscribed`. - */ - modelRequestContextWindow?: number + /** Latest atomic model-request snapshot on this mux generation. */ + modelRequest: ModelRequestTelemetry | null } diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index ff22ca92db..d53c9c4e94 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -2,7 +2,10 @@ // dispatch entry + list state, constructed and held by SessionsService (one per client runtime). // List data never enters zustand; React connects via subscribe/getListSnapshot. -import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' +import type { + HostFrame, IApiClient, ModelRequestTelemetry, MuxFrame, RpcError, RpcRequest, + RpcResult, SessionId, SessionSummary, WorkspaceId, +} from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -58,11 +61,11 @@ export class SessionManager { * frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */ private readonly pendingBuffers = new Map[]>() /** - * Latest model capacity observed for an uninstantiated session on the + * Latest request telemetry observed for an uninstantiated session on the * current mux generation. Unlike durable history, this transient frame * cannot be backfilled when get() lazily creates the Session. */ - private readonly modelRequestContextWindows = new Map() + private readonly modelRequests = new Map() /** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open * replays of the same requested frame). Manager-owned rather than read off Session instances * because the sidebar must light up for sessions never instantiated. Cleared per connection @@ -171,7 +174,7 @@ export class SessionManager { } private createSession(sessionId: SessionId): Session { - const modelRequestContextWindow = this.modelRequestContextWindows.get(sessionId) + const modelRequest = this.modelRequests.get(sessionId) return new Session(sessionId, this.api, { // The sender's local first-send flip mirrors into the list row so the // session surfaces (lists filter on blank) before any host frame lands. @@ -179,7 +182,7 @@ export class SessionManager { this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) }, projections: this.projectionStore(sessionId), - ...(modelRequestContextWindow === undefined ? {} : { modelRequestContextWindow }), + ...(modelRequest === undefined ? {} : { modelRequest }), }) } @@ -355,13 +358,13 @@ export class SessionManager { return } if (frame.type === 'session/model-request') { - // Transient and non-replayable: retain the latest capacity until lazy - // instantiation. An absent value explicitly clears an earlier one. - if (frame.contextWindow === undefined) this.modelRequestContextWindows.delete(frame.sessionId) - else this.modelRequestContextWindows.set(frame.sessionId, frame.contextWindow) + // Transient and non-replayable: retain the whole latest request until + // lazy instantiation. Missing fields replace rather than inherit. + const { type: _type, sessionId, ...modelRequest } = frame + this.modelRequests.set(sessionId, modelRequest) } if (frame.type === 'session/subscribed') { - this.modelRequestContextWindows.delete(frame.sessionId) + this.modelRequests.delete(frame.sessionId) // Rows past the host's durable baseline rode state a restart lost; drop // them so last-wins cannot pin a phantom value over recomputed truth. this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) @@ -440,7 +443,7 @@ export class SessionManager { this.recordMutation({ kind: 'remove', sessionId: frame.sessionId }) this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation - this.modelRequestContextWindows.delete(frame.sessionId) // connection-local request capacity dies with the Host session + this.modelRequests.delete(frame.sessionId) // connection-local request telemetry dies with the Host session this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance return @@ -481,7 +484,7 @@ export class SessionManager { if (kept.length === 0) this.pendingBuffers.delete(sessionId) else this.pendingBuffers.set(sessionId, kept) } - this.modelRequestContextWindows.clear() + this.modelRequests.clear() for (const session of this.sessions.values()) session.handleReconnecting() } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 8b99940009..765c3f5fa1 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, - SessionId, SessionMetrics, ToolEventView, + ModelRequestTelemetry, SessionId, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. @@ -43,8 +43,8 @@ export interface SessionOptions { * private store (bare object-layer construction). */ projections?: ProjectionValueStore - /** Model capacity already observed on this mux generation before lazy construction. */ - modelRequestContextWindow?: number + /** Request telemetry already observed on this mux generation before lazy construction. */ + modelRequest?: ModelRequestTelemetry } /** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */ @@ -110,10 +110,8 @@ export class Session implements SessionFace { private queueCache: { rev: number; value: QueuedMessage[] } | null = null private frozenRev = 0 private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null - /** Host-owned durable usage/current-pressure projection. */ - private metrics: SessionMetrics | null = null - /** Latest capacity observed on this mux connection, independent of durable metrics arrival. */ - private contextWindow: number | undefined + /** Latest atomic request snapshot observed on this mux connection. */ + private modelRequest: ModelRequestTelemetry | null /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends * copy-on-write the per-parent array so published snapshot references never mutate. */ private codeDispatches = new Map() @@ -175,7 +173,7 @@ export class Session implements SessionFace { private readonly options: SessionOptions = {}, ) { this.projections = options.projections ?? new ProjectionValueStore() - this.contextWindow = options.modelRequestContextWindow + this.modelRequest = options.modelRequest ?? null this.snapshotCache = this.buildSnapshot() } @@ -331,10 +329,10 @@ export class Session implements SessionFace { * in-flight open first — its history request rode the dead connection and must not settle * the fresh generation into 'error' (audit S4). */ async resync(): Promise { - // Queue, metrics, and request capacity are NOT cleared here: onConnected + // Queue and request telemetry are NOT cleared here: onConnected // (which drives resync) races the mux frames — fresh-generation state may - // have landed already, and the host never resends it. session/subscribed - // owns the generation reset before the queue snapshot and metrics frames. + // have landed already, and the host never resends request telemetry. + // session/subscribed owns the reset before the queue snapshot. if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) this.openGeneration++ this.openPromise = null @@ -415,24 +413,22 @@ export class Session implements SessionFace { this.queueRev++ changed = true } - if (this.contextWindow !== undefined) { - this.contextWindow = undefined - changed = true - } - if (this.metrics !== null) { - this.metrics = null + if (this.modelRequest !== null) { + this.modelRequest = null changed = true } if (changed) this.notifier.markDirty() return } - case 'session/metrics': { - this.installMetrics(frame.metrics) - return - } case 'session/model-request': { - if (this.contextWindow === frame.contextWindow) return - this.contextWindow = frame.contextWindow + const { + type: _type, + sessionId: _sessionId, + ...modelRequest + } = frame + // Whole-frame replacement is load-bearing: an omitted numerator or + // capacity clears that field from the preceding request. + this.modelRequest = modelRequest this.notifier.markDirty() return } @@ -508,17 +504,16 @@ export class Session implements SessionFace { /** Connection-loss boundary: clear values that are not replayed before the next stream starts. */ handleReconnecting(): void { this.openGeneration++ - if (this.metrics === null && this.contextWindow === undefined) return - this.metrics = null - this.contextWindow = undefined + if (this.modelRequest === null) return + this.modelRequest = null this.notifier.markDirty() } - /** host/session-removed relay: flag the resident snapshot and clear connection-local capacity. */ + /** host/session-removed relay: flag the resident snapshot and clear request telemetry. */ handleRemoved(): void { - const changed = !this.removed || this.contextWindow !== undefined + const changed = !this.removed || this.modelRequest !== null this.removed = true - this.contextWindow = undefined + this.modelRequest = null if (changed) this.notifier.markDirty() } @@ -567,7 +562,6 @@ export class Session implements SessionFace { result.value.events, result.value.hasMore, result.value.projections, - result.value.metrics, ) // Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more. const tailSeq = this.windowTailSeq() @@ -579,7 +573,6 @@ export class Session implements SessionFace { result.value.events, result.value.hasMore, result.value.projections, - result.value.metrics, ) } } @@ -606,7 +599,6 @@ export class Session implements SessionFace { entries: HistoryEntry[], hasMore: boolean, projections: ProjectionsBaseline | undefined, - metrics: SessionMetrics | undefined, ): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) @@ -615,7 +607,6 @@ export class Session implements SessionFace { this.foldAdapter.reset(this.events, this.baseSeq, this.views) this.rebuildDerivedFromWindow() if (projections !== undefined) this.projections.seed(projections) - if (metrics !== undefined) this.installMetrics(metrics) const buffered = this.liveBuffer this.liveBuffer = [] for (const item of buffered) this.appendLive(item.event, item.view) @@ -668,7 +659,6 @@ export class Session implements SessionFace { result.value.events, result.value.hasMore, result.value.projections, - result.value.metrics, ) } } catch (error) { @@ -854,20 +844,6 @@ export class Session implements SessionFace { return tail === undefined ? null : tail.seq } - /** Install a metrics snapshot unless a newer durable or publication revision already landed. */ - private installMetrics(metrics: SessionMetrics): void { - const current = this.metrics - if ( - current !== null - && ( - metrics.logRevision < current.logRevision - || metrics.projectionRevision < current.projectionRevision - ) - ) return - this.metrics = metrics - this.notifier.markDirty() - } - private buildSnapshot(): ConversationSnapshot { const { nodes: folded, degraded } = this.foldAdapter.nodes() // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order. @@ -920,10 +896,7 @@ export class Session implements SessionFace { promptError: this.promptError, blank: this.blankBit, lastAgentError: this.lastAgentError, - metrics: this.metrics, - ...(this.contextWindow === undefined - ? {} - : { modelRequestContextWindow: this.contextWindow }), + modelRequest: this.modelRequest, } } } diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 7399c46f6b..1e2937b464 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -102,7 +102,7 @@ describe('runtime client apply', () => { expect(bench.api.callsOf('session.create')).toHaveLength(1) }) - it('clears connection-local Session state on disconnect but not connected', async () => { + it('clears connection-local request telemetry on reconnect but not connected', async () => { const bench = await mount() const sessions = bench.ctx.get('sessions') as SessionsService bench.sinks?.onHostEnvelope?.({ @@ -112,21 +112,8 @@ describe('runtime client apply', () => { await Promise.resolve() const session = sessions.binding('s-state' as never)?.session if (session === undefined) throw new Error('session binding missing') - const currentMetrics = { - projectionRevision: 4, - logRevision: 10, - uncachedInputTokens: 10, - outputTokens: 4, - cacheReadTokens: 90, - cacheWriteTokens: 3, - contextTokens: 35, - } bench.sinks?.onMuxEnvelope?.({ - rpcId: 'metrics' as never, - payload: { type: 'session/metrics', sessionId: 's-state', metrics: currentMetrics } as never, - }) - bench.sinks?.onMuxEnvelope?.({ - rpcId: 'capacity' as never, + rpcId: 'request' as never, payload: { type: 'session/model-request', sessionId: 's-state', @@ -134,19 +121,20 @@ describe('runtime client apply', () => { step: 1, provider: 'test', model: 'alpha', + contextTokens: 32_000, contextWindow: 128_000, } as never, }) bench.sinks?.onConnected?.() - expect(session.getSnapshot()).toMatchObject({ - metrics: currentMetrics, - modelRequestContextWindow: 128_000, + expect(session.getSnapshot().modelRequest).toMatchObject({ + model: 'alpha', + contextTokens: 32_000, + contextWindow: 128_000, }) - bench.sinks?.onDisconnected?.() - expect(session.getSnapshot().metrics).toBeNull() - expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + bench.sinks?.onStateChange?.('reconnecting') + expect(session.getSnapshot().modelRequest).toBeNull() }) it('stops the stream loop when the plugin fiber unloads', async () => { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 829417e44b..0654446c5b 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -4,7 +4,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame, - RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionMetrics, SessionModels, + RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionProjectionsBlock, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' @@ -69,7 +69,6 @@ export class FakeApiClient implements IApiClient { events: never[] hasMore: boolean projections?: SessionProjectionsBlock - metrics?: SessionMetrics }>> = () => Promise.resolve(ok({ events: [], hasMore: false })) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 62f4cc32d1..d5a4237fc1 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId, SessionMetrics } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' import { entries, plainTurn } from './event-script.ts' @@ -41,7 +41,7 @@ describe('instances', () => { expect(manager.get(S2).getSnapshot().pending).toEqual([]) }) - it('retains the latest transient model capacity until lazy instantiation', () => { + it('retains the latest transient request snapshot until lazy instantiation', () => { const api = new FakeApiClient() const manager = new SessionManager(api) manager.handleMuxEnvelope({ @@ -53,6 +53,7 @@ describe('instances', () => { step: 1, provider: 'test', model: 'alpha', + contextTokens: 12_000, contextWindow: 128_000, }, }) @@ -65,14 +66,22 @@ describe('instances', () => { step: 2, provider: 'test', model: 'beta', + contextTokens: 32_000, contextWindow: 256_000, }, }) - expect(manager.get(S1).getSnapshot().modelRequestContextWindow).toBe(256_000) + expect(manager.get(S1).getSnapshot().modelRequest).toEqual({ + turn: 1, + step: 2, + provider: 'test', + model: 'beta', + contextTokens: 32_000, + contextWindow: 256_000, + }) }) - it('retains explicit capacity clearing before lazy instantiation', () => { + it('retains whole-frame replacement before lazy instantiation', () => { const api = new FakeApiClient() const manager = new SessionManager(api) manager.handleMuxEnvelope({ @@ -99,10 +108,15 @@ describe('instances', () => { }, }) - expect(manager.get(S1).getSnapshot().modelRequestContextWindow).toBeUndefined() + expect(manager.get(S1).getSnapshot().modelRequest).toEqual({ + turn: 1, + step: 2, + provider: 'test', + model: 'unknown-capacity', + }) }) - it('clears retained capacity on subscribed and resident capacity on removal', () => { + it('clears retained request telemetry on subscribed and removal', () => { const api = new FakeApiClient() const manager = new SessionManager(api) manager.handleMuxEnvelope({ @@ -122,7 +136,7 @@ describe('instances', () => { payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 0 }, }) const session = manager.get(S1) - expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + expect(session.getSnapshot().modelRequest).toBeNull() manager.handleMuxEnvelope({ rpcId: 'request-after-subscribe' as never, @@ -136,12 +150,12 @@ describe('instances', () => { contextWindow: 256_000, }, }) - expect(session.getSnapshot().modelRequestContextWindow).toBe(256_000) + expect(session.getSnapshot().modelRequest?.contextWindow).toBe(256_000) manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 }, }) - expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + expect(session.getSnapshot().modelRequest).toBeNull() manager.handleMuxEnvelope({ rpcId: 'request-before-lazy-removal' as never, @@ -159,28 +173,15 @@ describe('instances', () => { rpcId: 'lazy-removed' as never, payload: { type: 'host/session-removed', sessionId: S2 }, }) - expect(manager.get(S2).getSnapshot().modelRequestContextWindow).toBeUndefined() + expect(manager.get(S2).getSnapshot().modelRequest).toBeNull() }) - it('clears resident metrics and capacity plus lazy capacity before reconnect', () => { + it('clears resident and lazy request telemetry on disconnect', () => { const api = new FakeApiClient() const manager = new SessionManager(api) const session = manager.get(S1) - const currentMetrics: SessionMetrics = { - projectionRevision: 4, - logRevision: 10, - uncachedInputTokens: 10, - outputTokens: 4, - cacheReadTokens: 90, - cacheWriteTokens: 3, - contextTokens: 35, - } manager.handleMuxEnvelope({ - rpcId: 'metrics' as never, - payload: { type: 'session/metrics', sessionId: S1, metrics: currentMetrics }, - }) - manager.handleMuxEnvelope({ - rpcId: 'resident-capacity' as never, + rpcId: 'resident-request' as never, payload: { type: 'session/model-request', sessionId: S1, @@ -188,11 +189,12 @@ describe('instances', () => { step: 1, provider: 'test', model: 'resident', + contextTokens: 35, contextWindow: 128_000, }, }) manager.handleMuxEnvelope({ - rpcId: 'lazy-capacity' as never, + rpcId: 'lazy-request' as never, payload: { type: 'session/model-request', sessionId: S2, @@ -200,19 +202,20 @@ describe('instances', () => { step: 1, provider: 'test', model: 'lazy', + contextTokens: 70, contextWindow: 256_000, }, }) - expect(session.getSnapshot()).toMatchObject({ - metrics: currentMetrics, - modelRequestContextWindow: 128_000, + expect(session.getSnapshot().modelRequest).toMatchObject({ + model: 'resident', + contextTokens: 35, + contextWindow: 128_000, }) - manager.handleReconnecting() + manager.handleDisconnected() - expect(session.getSnapshot().metrics).toBeNull() - expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() - expect(manager.get(S2).getSnapshot().modelRequestContextWindow).toBeUndefined() + expect(session.getSnapshot().modelRequest).toBeNull() + expect(manager.get(S2).getSnapshot().modelRequest).toBeNull() }) it('caps the pending buffer at 32 keeping the newest, and drops it on session-removed', () => { diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 827e9f853e..3994a691cb 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { - SessionId, SessionMetrics, SessionProjectionsBlock, + SessionId, SessionProjectionsBlock, } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' @@ -29,34 +29,15 @@ function histResponse( events: SessionEvent[], hasMore = false, projections?: SessionProjectionsBlock, - metrics?: SessionMetrics, ) { // history now returns HistoryEntry[] ({event, view?}); these tests are view-less. return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...projections === undefined ? {} : { projections }, - ...metrics === undefined ? {} : { metrics }, })) } -function metrics( - projectionRevision: number, - logRevision: number, - over: Partial = {}, -): SessionMetrics { - return { - projectionRevision, - logRevision, - uncachedInputTokens: 10, - outputTokens: 4, - cacheReadTokens: 90, - cacheWriteTokens: 3, - contextTokens: 35, - ...over, - } -} - describe('open', () => { it('installs the tail page: cold → loading → open with window and nodes in place', async () => { const { api, session } = makeSession() @@ -70,19 +51,7 @@ describe('open', () => { expect(snapshot.openState).toBe('open') expect(snapshot.hasMore).toBe(true) expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant']) - expect(snapshot.metrics).toBeNull() - }) - - it('installs full-log metrics independently of older history pages', async () => { - const { api, session } = makeSession() - const tailMetrics = metrics(4, 106) - api.onHistory = () => histResponse(plainTurn(100, 3, '问', '答'), true, undefined, tailMetrics) - await session.open() - expect(session.getSnapshot().metrics).toBe(tailMetrics) - - api.onHistory = () => histResponse(plainTurn(94, 2, '旧问', '旧答')) - await session.loadOlder() - expect(session.getSnapshot().metrics).toBe(tailMetrics) + expect(snapshot.modelRequest).toBeNull() }) it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => { @@ -147,17 +116,8 @@ describe('live event path', () => { expect(session.getSnapshot().nodes).toEqual(before.nodes) }) - it('keeps live capacity separate from durable metrics, replaces or clears it on requests, and resets at subscription', async () => { + it('replaces the whole request snapshot, clears omitted fields, and resets at subscription', async () => { const { session } = await opened() - const current = metrics(8, 10) - session.handleMuxEnvelope('m1' as never, { - type: 'session/metrics', - sessionId: SID, - metrics: current, - }) - expect(session.getSnapshot().metrics).toBe(current) - expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() - session.handleMuxEnvelope('request-1' as never, { type: 'session/model-request', sessionId: SID, @@ -165,32 +125,17 @@ describe('live event path', () => { step: 1, provider: 'test', model: 'alpha', + contextTokens: 32_000, contextWindow: 128_000, }) - expect(session.getSnapshot().metrics).toBe(current) - expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000) - - session.handleMuxEnvelope('m2' as never, { - type: 'session/metrics', - sessionId: SID, - metrics: metrics(9, 9, { uncachedInputTokens: 1 }), + expect(session.getSnapshot().modelRequest).toEqual({ + turn: 1, + step: 1, + provider: 'test', + model: 'alpha', + contextTokens: 32_000, + contextWindow: 128_000, }) - session.handleMuxEnvelope('m3' as never, { - type: 'session/metrics', - sessionId: SID, - metrics: metrics(7, 11, { uncachedInputTokens: 2 }), - }) - expect(session.getSnapshot().metrics).toBe(current) - expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000) - - const ordinaryUpdate = metrics(9, 11, { contextTokens: 40 }) - session.handleMuxEnvelope('m4' as never, { - type: 'session/metrics', - sessionId: SID, - metrics: ordinaryUpdate, - }) - expect(session.getSnapshot().metrics).toBe(ordinaryUpdate) - expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000) session.handleMuxEnvelope('request-2' as never, { type: 'session/model-request', @@ -200,16 +145,19 @@ describe('live event path', () => { provider: 'test', model: 'without-capacity', }) - expect(session.getSnapshot().metrics).toEqual(ordinaryUpdate) - expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + expect(session.getSnapshot().modelRequest).toEqual({ + turn: 2, + step: 1, + provider: 'test', + model: 'without-capacity', + }) session.handleMuxEnvelope('sub' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 5, }) - expect(session.getSnapshot().metrics).toBeNull() - expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + expect(session.getSnapshot().modelRequest).toBeNull() session.handleMuxEnvelope('request-3' as never, { type: 'session/model-request', sessionId: SID, @@ -217,19 +165,17 @@ describe('live event path', () => { step: 1, provider: 'test', model: 'beta', + contextTokens: 20, contextWindow: 256_000, }) - const nextGeneration = metrics(0, 10, { contextTokens: 20 }) - session.handleMuxEnvelope('m5' as never, { - type: 'session/metrics', - sessionId: SID, - metrics: nextGeneration, + expect(session.getSnapshot().modelRequest).toMatchObject({ + turn: 3, + contextTokens: 20, + contextWindow: 256_000, }) - expect(session.getSnapshot().metrics).toBe(nextGeneration) - expect(session.getSnapshot().modelRequestContextWindow).toBe(256_000) }) - it('publishes a subscribed reset when capacity arrived before durable metrics', async () => { + it('publishes a subscribed reset when request telemetry arrived first', async () => { const { session } = await opened() session.handleMuxEnvelope('request' as never, { type: 'session/model-request', @@ -238,17 +184,17 @@ describe('live event path', () => { step: 1, provider: 'test', model: 'alpha', + contextTokens: 8_000, contextWindow: 128_000, }) - expect(session.getSnapshot().metrics).toBeNull() - expect(session.getSnapshot().modelRequestContextWindow).toBe(128_000) + expect(session.getSnapshot().modelRequest?.contextWindow).toBe(128_000) session.handleMuxEnvelope('sub' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 5, }) - expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() + expect(session.getSnapshot().modelRequest).toBeNull() }) it('materializes a command node from live lifecycle frames and reproduces it from a history window', async () => { @@ -876,72 +822,61 @@ describe('remaining branches', () => { }) describe('resync', () => { - it('fences pre-disconnect history behind a fresh mux metrics baseline', async () => { + it('clears request telemetry on reconnect and drops a stale in-flight history response', async () => { const { api, session } = makeSession() const stale = deferred>>() api.onHistory = () => stale.promise const opening = session.open() - const oldLiveMetrics = metrics(8, 10) - session.handleMuxEnvelope('old-metrics' as never, { - type: 'session/metrics', - sessionId: SID, - metrics: oldLiveMetrics, - }) - session.handleMuxEnvelope('old-capacity' as never, { + session.handleMuxEnvelope('old-request' as never, { type: 'session/model-request', sessionId: SID, turn: 1, step: 1, provider: 'test', model: 'old', + contextTokens: 20, contextWindow: 128_000, }) session.handleReconnecting() - expect(session.getSnapshot().metrics).toBeNull() - expect(session.getSnapshot().modelRequestContextWindow).toBeUndefined() - const freshMetrics = metrics(0, 1, { contextTokens: 20 }) - session.handleMuxEnvelope('fresh-metrics' as never, { - type: 'session/metrics', - sessionId: SID, - metrics: freshMetrics, - }) + expect(session.getSnapshot().modelRequest).toBeNull() stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧问', '旧答')) as never[], hasMore: false, - metrics: metrics(99, 99, { contextTokens: 999 }), })) await opening expect(session.getSnapshot().nodes).toEqual([]) - expect(session.getSnapshot().metrics).toBe(freshMetrics) + expect(session.getSnapshot().modelRequest).toBeNull() api.onHistory = () => histResponse(plainTurn(6, 1, '新问', '新答')) await session.resync() expect(session.getSnapshot().openState).toBe('open') expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([7, 9]) - expect(session.getSnapshot().metrics).toBe(freshMetrics) + expect(session.getSnapshot().modelRequest).toBeNull() }) - it('preserves fresh-generation metrics that arrive before a failing history refresh', async () => { + it('preserves a fresh-generation request snapshot when history resync fails', async () => { const { api, session } = makeSession() - const oldMetrics = metrics(8, 10) - api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'), false, undefined, oldMetrics) + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) await session.open() - expect(session.getSnapshot().metrics).toBe(oldMetrics) session.handleMuxEnvelope('sub' as never, { type: 'session/subscribed', sessionId: SID, lastSeq: 5, }) - expect(session.getSnapshot().metrics).toBeNull() + expect(session.getSnapshot().modelRequest).toBeNull() - const freshMetrics = metrics(0, 10, { contextTokens: 20 }) - session.handleMuxEnvelope('fresh-metrics' as never, { - type: 'session/metrics', + session.handleMuxEnvelope('fresh-request' as never, { + type: 'session/model-request', sessionId: SID, - metrics: freshMetrics, + turn: 2, + step: 1, + provider: 'test', + model: 'fresh', + contextTokens: 20, + contextWindow: 256_000, }) api.onHistory = () => Promise.resolve(err({ code: 'internal', @@ -953,7 +888,14 @@ describe('resync', () => { expect(session.getSnapshot()).toMatchObject({ openState: 'error', - metrics: freshMetrics, + modelRequest: { + turn: 2, + step: 1, + provider: 'test', + model: 'fresh', + contextTokens: 20, + contextWindow: 256_000, + }, }) }) diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 4219d233e2..7c9d74aae7 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -62,6 +62,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot promptError: null, blank: false, lastAgentError: null, + modelRequest: null, } } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6508a18b98..388b2044b8 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: b74127ffe11df40fcad0c97e2fc6896ec79ad9d1 -README.zh.md: f6dfc3ca61ea80758c9f64ca98f034b0832ee89e +README.md: c60a38ec26d4bca15ce23e51dbaedf3ef36022e4 +README.zh.md: 65c5554a78960fc4f86a6a370772c95429c648c4 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b74127ffe1..c60a38ec26 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -20,7 +20,7 @@ Per-session UI state for selection and the active view lives in the declared cha The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. -The chat stats line reads durable token counters/current pressure from `ConversationSnapshot.metrics` and joins them only at presentation with the separate connection-local `modelRequestContextWindow`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy only after the current mux connection observes a model request with capacity. Before that request, after reconnect/restore/new subscription, or after a request without capacity, the percentage is omitted and context is labeled unknown rather than queried ahead or reconstructed from history. +The chat stats line reads full-log billing from the generic `tokenUsage` projection and joins it only at presentation with the connection-local atomic `ConversationSnapshot.modelRequest`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy only when the same observed request snapshot contains both `contextTokens` and `contextWindow`. Before that request, after reconnect/restore/new subscription, or after a request missing either field, context is labeled unknown rather than queried from the selected model or reconstructed from history. The existing inline stats row remains the sole context UI; the model selector has no circle or accessory. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f6dfc3ca61..65c5554a78 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -20,7 +20,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 -聊天统计行从 `ConversationSnapshot.metrics` 读取持久的 token 计数/当前压力,并且只在展示时把它们与独立的连接本地 `modelRequestContextWindow` 结合;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并且只有当前 mux 连接观察到带容量的模型请求后才显示上下文占用率。在该请求之前、重连/恢复/新订阅之后,或在请求不带容量之后,系统都会省略百分比,并把上下文标为「未知」,而不会提前查询或根据历史记录重建。 +聊天统计行从通用 `tokenUsage` 投影读取完整日志计费用量,并且只在展示时把它与连接本地的原子快照 `ConversationSnapshot.modelRequest` 结合;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并且只有同一份已观测请求快照同时包含 `contextTokens` 与 `contextWindow` 时才显示上下文占用率。在该请求之前、重连/恢复/新订阅之后,或在请求缺少任一字段之后,系统都会把上下文标为「未知」,而不会从所选模型查询或根据历史记录重建。现有的行内统计行仍是唯一的上下文 UI;模型选择器不增加圆环或附属控件。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 42812dce24..88be12b5af 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" }, @@ -52,6 +53,7 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 1cbb00a4ce..36a2cde7b9 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -221,7 +221,9 @@ function StreamingTail({ useSession, onGrow }: { * The chat view slot entry: pure component over the composed props (tool rows * render through the declared keyed hole's renderSlot share). */ -export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) { +export function ChatView({ + useProjection, useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, +}: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -381,7 +383,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio {running && } - + {!atBottom && ( + ) + } + + return ( +
+
+ {summaryText(props, shown)} + {truncated && {`已截断 · 共 ${total}`}} + {!empty && ( + + )} +
+ {empty + ?
无结果
+ : ( +
+ {(capped ? rows.slice(0, headLines) : rows).map(row => ( +
{renderRow(row)}
+ ))} + {hidden > 0 && ( + + )} + {capped && rows.slice(rows.length - tailLines).map(row => ( +
{renderRow(row)}
+ ))} +
+ )} +
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index aa674f7a1a..2a53f67c1c 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -22,6 +22,10 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps } from './TerminalBlock.tsx' +export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx' +export type { + SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch, +} from './SearchBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/tests/search-block.spec.tsx b/packages/client/ui-primitives/tests/search-block.spec.tsx new file mode 100644 index 0000000000..37da021663 --- /dev/null +++ b/packages/client/ui-primitives/tests/search-block.spec.tsx @@ -0,0 +1,196 @@ +// @vitest-environment jsdom +// SearchBlock: both kinds (grouped grep matches and a flat glob path list), the +// truncation pill, the empty arm, per-file collapse/expand, the head/tail height +// cap and its expand control, and the copy control writing the whole structured +// result on both the accepted and refused clipboard paths. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { DEFAULT_SEARCH_MAX_LINES, SearchBlock } from '../src/index.ts' +import type { SearchFileGroup } from '../src/index.ts' + +afterEach(cleanup) + +beforeEach(() => { + vi.useRealTimers() +}) + +/** The rendered result rows, one string per visible row (CSS-module class prefix). */ +function lines(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '') +} + +/** The file-group header rows, one string per header (path + count concatenated). */ +function fileHeaders(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class^="_fileHeader_"]')].map(row => row.textContent ?? '') +} + +/** `count` numbered match lines under one file, without a terminating newline. */ +function group(path: string, count: number, from = 1): SearchFileGroup { + return { + path, + matches: Array.from({ length: count }, (_v, i) => ({ lineNumber: from + i, line: `hit ${from + i}` })), + } +} + +describe('SearchBlock matches kind', () => { + it('renders each file as a header group with its matched lines', () => { + const view = render() + expect(fileHeaders(view.container)).toEqual(['a.ts2', 'b.ts1']) + expect(lines(view.container)).toEqual(['12: const a = 1', '40: return a', '7: const b = 2']) + // The summary counts matches and files, no truncation pill under the cap. + expect(view.getByText('3 处匹配 · 2 个文件')).toBeTruthy() + expect(view.queryByText(/已截断/u)).toBeNull() + }) + + it('collapses and re-expands a single file group without touching the others', () => { + const view = render() + const [headerA] = view.container.querySelectorAll('[class^="_fileHeader_"]') + expect(headerA!.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(headerA!) + // a.ts collapsed: its match row is gone, b.ts's stays. + expect(headerA!.getAttribute('aria-expanded')).toBe('false') + expect(lines(view.container)).toEqual(['2: y']) + fireEvent.click(headerA!) + expect(lines(view.container)).toEqual(['1: x', '2: y']) + }) + + it('shows the truncation pill with the pre-cap total', () => { + const view = render() + expect(view.getByText('已截断 · 共 99')).toBeTruthy() + expect(view.getByText('2 处匹配 · 1 个文件')).toBeTruthy() + }) +}) + +describe('SearchBlock paths kind', () => { + it('renders a flat path list with a path-count summary', () => { + const view = render() + expect(lines(view.container)).toEqual(['src/a.ts', 'src/b.ts']) + expect(view.getByText('2 个路径')).toBeTruthy() + // No file-group headers in the paths shape. + expect(fileHeaders(view.container)).toEqual([]) + }) + + it('shows the truncation pill with the pre-cap total', () => { + const view = render() + expect(view.getByText('已截断 · 共 50')).toBeTruthy() + }) +}) + +describe('SearchBlock empty arm', () => { + it('shows the placeholder and no copy control for an empty matches result', () => { + const view = render() + expect(view.getByText('无结果')).toBeTruthy() + expect(view.queryByText('复制')).toBeNull() + expect(view.getByText('0 处匹配 · 0 个文件')).toBeTruthy() + }) + + it('shows the placeholder for an empty paths result', () => { + const view = render() + expect(view.getByText('无结果')).toBeTruthy() + expect(view.queryByText('复制')).toBeNull() + }) +}) + +describe('SearchBlock height cap', () => { + it('renders every row and no expand control under the cap', () => { + const view = render() + expect(lines(view.container)).toHaveLength(4) + expect(view.container.querySelector('[aria-label^="展开"]')).toBeNull() + }) + + it('slices head and tail over the cap and expands on click', () => { + const paths = Array.from({ length: 10 }, (_v, i) => `p${i + 1}`) + const view = render() + // maxLines 4: head = ceil(4/2) = 2, tail = 2, 6 hidden. + expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10']) + const toggle = view.getByRole('button', { name: '展开其余 6 行结果' }) + expect(toggle.textContent).toBe('… 其余 6 行') + fireEvent.click(toggle) + expect(lines(view.container)).toHaveLength(10) + const collapse = view.getByRole('button', { name: '收起结果' }) + expect(collapse.textContent).toBe('收起') + fireEvent.click(collapse) + expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10']) + }) + + it('counts a file header as one capped row alongside its matches', () => { + // One file with 10 matches → 11 rows (header + 10). Cap 4: head 2, tail 2. + const view = render() + // Head takes the header then the first match; tail takes the last two matches. + expect(lines(view.container)).toEqual(['1: hit 1', '9: hit 9', '10: hit 10']) + expect(fileHeaders(view.container)).toEqual(['a.ts10']) + expect(view.getByRole('button', { name: '展开其余 7 行结果' })).toBeTruthy() + }) + + it('renders the head slice alone when the cap leaves no tail', () => { + const view = render() + expect(lines(view.container)).toEqual(['a']) + expect(view.getByRole('button', { name: '展开其余 4 行结果' })).toBeTruthy() + }) + + it('caps at the documented default when maxLines is absent', () => { + const paths = Array.from({ length: DEFAULT_SEARCH_MAX_LINES + 1 }, (_v, i) => `p${i}`) + const view = render() + expect(lines(view.container)).toHaveLength(DEFAULT_SEARCH_MAX_LINES) + expect(view.getByRole('button', { name: '展开其余 1 行结果' })).toBeTruthy() + }) +}) + +describe('SearchBlock copy', () => { + it('copies the whole structured matches result, not the collapsed or capped view', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const view = render() + // Collapse a group and leave the cap in place: the clipboard still gets it all. + fireEvent.click(view.container.querySelector('[class^="_fileHeader_"]')!) + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('a.ts\n1: x\n2: y\n\nb.ts\n3: z') + await act(async () => { await Promise.resolve() }) + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + // A second click while the ok label shows is a no-op. + fireEvent.click(screen.getByRole('button', { name: '复制成功' })) + expect(writeText).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1000) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('copies the newline-joined path list for the paths shape', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('src/a.ts\nsrc/b.ts') + expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy() + }) + + it('does not claim success when the host refuses the write', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + await act(async () => { await Promise.resolve() }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull() + }) + + it('merges className onto the wrapper and tags the wrapper with the kind', () => { + const view = render() + expect(view.container.firstElementChild?.classList.contains('x')).toBe(true) + expect(view.container.firstElementChild?.getAttribute('data-search')).toBe('paths') + }) +}) From 2928c65ccd33cc02d012fc3f41be848c82f5e284 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 18:09:03 +0800 Subject: [PATCH 32/47] feat(web): fold the search truncation total into the summary line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate '已截断 · 共 N' pill with '显示 X / 共 N 处匹配 · K 个文件' (and '显示 X / 共 N 个路径' for glob), mirroring the read card's '显示 X / Y 行', so the retained count and the pre-cap total read as one clause instead of two numbers that appear to disagree. --- .../ui-primitives/src/SearchBlock.module.css | 5 ----- .../client/ui-primitives/src/SearchBlock.tsx | 21 +++++++++++-------- .../ui-primitives/tests/search-block.spec.tsx | 11 +++++----- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/client/ui-primitives/src/SearchBlock.module.css b/packages/client/ui-primitives/src/SearchBlock.module.css index 79902de6a3..8f46cdb226 100644 --- a/packages/client/ui-primitives/src/SearchBlock.module.css +++ b/packages/client/ui-primitives/src/SearchBlock.module.css @@ -37,11 +37,6 @@ color: var(--dsw-alias-label-secondary); } -.truncated { - flex: none; - color: var(--dsw-alias-state-business-primary); -} - .copyButton { flex: none; background-color: transparent; diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx index 98d1edc808..dbb4a289ea 100644 --- a/packages/client/ui-primitives/src/SearchBlock.tsx +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -10,7 +10,6 @@ import { useCallback, useMemo, useState, type ReactNode } from 'react' import clsx from 'clsx' import { writeClipboard } from './clipboard.ts' -import { Pill } from './Pill.tsx' import css from './SearchBlock.module.css' /** @@ -109,17 +108,22 @@ function shownCount(props: SearchBlockProps): number { } /** - * The banner summary: the structural count of the retained result. The - * truncation pill beside it carries the capped-vs-complete signal, so this - * stays a plain count of what the card holds. + * The banner summary. When the search was capped it reads `显示 X / 共 N …` so + * the retained count and the pre-cap total sit in one clause (mirroring the read + * card's `显示 X / Y 行`); when it was not capped it is a plain count of what the + * card holds. The unit — `处匹配 · K 个文件` for grep, `个路径` for glob — trails + * the count either way. * @param props - the card's props. * @param shown - the retained result count from {@link shownCount}. + * @param truncated - whether the search was capped. + * @param total - the pre-cap total the truncation clause reports. * @returns the summary text. */ -function summaryText(props: SearchBlockProps, shown: number): string { +function summaryText(props: SearchBlockProps, shown: number, truncated: boolean, total: number): string { + const count = truncated ? `显示 ${shown} / 共 ${total}` : `${shown}` return props.kind === 'paths' - ? `${shown} 个路径` - : `${shown} 处匹配 · ${props.files.length} 个文件` + ? `${count} 个路径` + : `${count} 处匹配 · ${props.files.length} 个文件` } /** @@ -227,8 +231,7 @@ export function SearchBlock(props: SearchBlockProps) { return (
- {summaryText(props, shown)} - {truncated && {`已截断 · 共 ${total}`}} + {summaryText(props, shown, truncated, total)} {!empty && ( )} - {capped && rows.slice(rows.length - tailLines).map(row => ( + {tailHeader !== undefined && ( +
{renderRow(tailHeader)}
+ )} + {tail.map(row => (
{renderRow(row)}
))}
diff --git a/packages/client/ui-primitives/tests/search-block.spec.tsx b/packages/client/ui-primitives/tests/search-block.spec.tsx index 511b681dc6..45a6664924 100644 --- a/packages/client/ui-primitives/tests/search-block.spec.tsx +++ b/packages/client/ui-primitives/tests/search-block.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom // SearchBlock: both kinds (grouped grep matches and a flat glob path list), the -// truncation pill, the empty arm, per-file collapse/expand, the head/tail height -// cap and its expand control, and the copy control writing the whole structured +// folded truncation summary, the empty arm, per-file collapse/expand, the +// head/tail height cap and its expand control, the tail slice restoring its +// owning file header, and the copy control writing the whole structured // result on both the accepted and refused clipboard paths. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -41,9 +42,9 @@ describe('SearchBlock matches kind', () => { ]} />) expect(fileHeaders(view.container)).toEqual(['a.ts2', 'b.ts1']) expect(lines(view.container)).toEqual(['12: const a = 1', '40: return a', '7: const b = 2']) - // The summary counts matches and files, no truncation pill under the cap. + // The summary counts matches and files, with no folded pre-cap total below the cap. expect(view.getByText('3 处匹配 · 2 个文件')).toBeTruthy() - expect(view.queryByText(/已截断/u)).toBeNull() + expect(view.queryByText(/显示|共/u)).toBeNull() }) it('collapses and re-expands a single file group without touching the others', () => { @@ -64,7 +65,6 @@ describe('SearchBlock matches kind', () => { it('folds the pre-cap total into the summary when truncated', () => { const view = render() expect(view.getByText('显示 2 / 共 99 处匹配 · 1 个文件')).toBeTruthy() - expect(view.queryByText(/已截断/u)).toBeNull() }) }) @@ -80,7 +80,6 @@ describe('SearchBlock paths kind', () => { it('folds the pre-cap total into the paths summary when truncated', () => { const view = render() expect(view.getByText('显示 2 / 共 50 个路径')).toBeTruthy() - expect(view.queryByText(/已截断/u)).toBeNull() }) }) @@ -139,6 +138,20 @@ describe('SearchBlock height cap', () => { expect(view.getByRole('button', { name: '展开其余 4 行结果' })).toBeTruthy() }) + it('restores the owning file header above a tail slice that begins mid-file', () => { + // Two files of 10 matches each → 22 rows. Cap 8: head 4 (a.ts header + 3 + // matches), tail 4 (last 4 of b.ts, whose header sits above the cut). + const view = render() + // The tail's own header is restored so its rows can be attributed to b.ts. + expect(fileHeaders(view.container)).toEqual(['a.ts10', 'b.ts10']) + expect(lines(view.container)).toEqual([ + '1: hit 1', '2: hit 2', '3: hit 3', + '17: hit 17', '18: hit 18', '19: hit 19', '20: hit 20', + ]) + }) + it('caps at the documented default when maxLines is absent', () => { const paths = Array.from({ length: DEFAULT_SEARCH_MAX_LINES + 1 }, (_v, i) => `p${i}`) const view = render() From 6608ede1a033735ea95409995bf0d5bc523f19f8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 20:49:23 +0800 Subject: [PATCH 35/47] fix(web-search-card): surface result text when an errored search has no card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grep/glob return no presentResult on an error result, so an errored search had no card and the keyed SearchRow showed only a red dot — the model-facing error text (bad pattern, missing path, a nested run_code dispatch with no card) was nowhere on screen. Add an error-text arm mirroring the file-mutation and read rows. Added tests for the text arm and its name/code fallback. The unknown-kind fallback in search-card-model is already guarded (returns null → generic path). --- .../client/toolviews/search-row.module.css | 11 ++++++++ .../src/client/toolviews/search-row.tsx | 25 +++++++++++++++++++ .../tests/search-card.spec.tsx | 19 ++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.module.css b/packages/client/ui-conversation/src/client/toolviews/search-row.module.css index 5c4eb1f7db..dd0395ec1d 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.module.css @@ -93,3 +93,14 @@ clip: rect(0 0 0 0); white-space: nowrap; } + +/* The result text for an errored search, indented to the card's own column and + in the error tone, standing in for the search card the failure path does not + produce. */ +.failure { + margin: 4px 0 4px 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx index 90ec5e3470..0726f30a3c 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx @@ -40,6 +40,27 @@ function stateStatus(state: ToolRowState): string | null { } } +/** + * A settled result's text, flattened from its content blocks, for the arm that + * shows a failure the search card cannot: grep/glob have no `presentResult` on + * an error result, so an errored search has no card, and the keyed row is not a + * details-panel target. Without this the failure — a bad pattern, a missing + * path, a nested run_code dispatch that returned no card — would read as a bare + * red dot with the model-facing error text nowhere on screen. + * @param block - the frozen call slice. + * @returns the result text, or null for a running call or an empty result. + */ +function errorText(block: ToolRowProps['block']): string | null { + if (!('kind' in block)) return null + const parts: string[] = [] + for (const item of block.content) { + if (item.type === 'text') parts.push(item.text) + } + if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) + const text = parts.join('\n') + return text === '' ? null : text +} + /** * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the * completed search's card resident below it. The summary row is not a @@ -51,6 +72,9 @@ export function SearchRow({ toolName, block }: ToolRowProps) { const model = toolRowModel(toolName, block) const search = searchCardModel(block) const status = stateStatus(model.state) + // An errored search has no card (grep/glob return no presentResult on error); + // surface its result text so the failure is more than a red dot. + const failure = search === null && model.state === 'error' ? errorText(block) : null return (
@@ -65,6 +89,7 @@ export function SearchRow({ toolName, block }: ToolRowProps) { {search !== null && ( )} + {failure !== null &&
{failure}
}
) } diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index c2ff38755c..6d566b1b01 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -184,6 +184,25 @@ describe('SearchRow keyed card', () => { expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error') }) + it('surfaces the result text when an errored search has no card', () => { + // grep/glob return no presentResult on error → no card; the row shows the + // model-facing error text instead of a bare red dot. + const view = render() + expect(searchKindOf(view.container)).toBeNull() + expect(view.getByText('grep: invalid regular expression')).toBeTruthy() + }) + + it('falls back to the error name/code when an errored result has no text block', () => { + const view = render() + expect(view.getByText('ToolError: timeout')).toBeTruthy() + }) + it('shows the result view\'s replacement title instead of the args summary', () => { const view = render( Date: Thu, 30 Jul 2026 20:58:34 +0800 Subject: [PATCH 36/47] test(web): isolate direct smoke skill homes Build-review integration round 2. --- apps/web/tests/smoke-real.e2e.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index d2b2d32f21..813f30d621 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -163,6 +163,8 @@ describe('dsh web keyless CLI smoke', () => { env: { ...process.env, DEEPSEEK_API_KEY: 'keyless-web-no-call', + DSH_HOME: join(sessionsDir, '.dsh'), + DSH_AGENTS_HOME: join(sessionsDir, '.agents'), TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), }, stdio: ['ignore', 'pipe', 'pipe'], @@ -222,6 +224,7 @@ describe('dsh web keyless CLI smoke', () => { DEEPSEEK_API_KEY: 'keyless-web-workspace', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, DSH_HOME: join(workspace, '.dsh'), + DSH_AGENTS_HOME: join(workspace, '.agents'), TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), }, stdio: ['ignore', 'pipe', 'pipe'], @@ -241,6 +244,8 @@ describe('dsh web keyless CLI smoke', () => { setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref() }), ]) + expect(captured.messages?.some(message => + message.role === 'user' && message.content?.includes(''))).toBe(false) const workspaceMessage = captured.messages?.find(message => message.role === 'user' && message.content?.includes('web-workspace-context-probe')) expect(workspaceMessage).toMatchInlineSnapshot(` @@ -310,6 +315,7 @@ describe('dsh web keyless CLI smoke', () => { DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, DSH_TOOLS_MODE: 'code', DSH_HOME: join(workspace, '.dsh'), + DSH_AGENTS_HOME: join(workspace, '.agents'), TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), }, stdio: ['ignore', 'pipe', 'pipe'], @@ -358,8 +364,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-')) const port = await probeFreePort() // tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate - // the global Harness home inside the temp world; tsx also needs the repo's - // loader and tsconfig paths pointed at explicitly. + // the host-level Harness and shared-agent homes inside the temp world; tsx + // also needs the repo's loader and tsconfig paths pointed at explicitly. const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href child = spawn( process.execPath, @@ -369,6 +375,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke env: { ...process.env, DSH_HOME: join(sessionsDir, '.dsh'), + DSH_AGENTS_HOME: join(sessionsDir, '.agents'), TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), }, stdio: ['ignore', 'pipe', 'pipe'], From e830ed793981e77cfc07acfb0b52bfbacb9ba132 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 22:40:07 +0800 Subject: [PATCH 37/47] fix(web-search-card): surface truncation recovery, widen cardless fallback, validate wire shape, fix tail-cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the ds-review-bot findings on the search card: - searchCardModel dropped the result view's `content`, so a capped search's `Full … stored at: ` recovery footer vanished from the UI (the card replaces the raw text). Thread it through as `SearchCardModel.recovery` and render it below the card at all three sites, only when truncated. - SearchRow's fallback body was gated on `state === 'error'`, so a settled non-error call with no card (a successful nested run_code sub-dispatch, a legacy generic result) showed only its summary with content lost. Widen it to any settled call with `search === null`. - searchCardModel trusted the `files`/`paths` shape the host wire schema only string-checks; a malformed known-kind frame would crash SearchBlock. Validate the full shape and fall to the generic path on mismatch. - SearchBlock's restored tail file header added a row without consuming a tail slot, exceeding maxLines by one and overstating the hidden count. Make it consume a slot so the visible count holds at maxLines and `hidden` stays exact. Correct the fixture JSDoc (now genuinely exceeds the row cap) and the Agent Note recovery-text claim, sync the ui-conversation bilingual README with the search row, and add an assembled keyless snapshot (apps/web/tests/search-card.snapshot.ts) that pins the grep card's shape from the built bundles. --- .../2026-07-30-web-search-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-search-card.md | 13 +- .../feature/2026-07-30-web-search-card.zh.md | 13 +- apps/web/tests/search-card.snapshot.ts | 161 ++++++++++++++++++ .../search-card/grep-card.expected.txt | 11 ++ .../client/connection/src/client/fixture.ts | 11 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/chat/ToolRow.module.css | 11 ++ .../src/client/chat/ToolRow.tsx | 11 +- .../src/client/contract/search-card-model.ts | 72 +++++++- .../client/skeleton/DetailsPanel.module.css | 11 ++ .../src/client/skeleton/DetailsPanel.tsx | 18 +- .../client/toolviews/search-row.module.css | 11 ++ .../src/client/toolviews/search-row.tsx | 39 +++-- .../tests/search-card.spec.tsx | 98 +++++++++++ .../client/ui-primitives/src/SearchBlock.tsx | 10 +- .../ui-primitives/tests/search-block.spec.tsx | 10 +- 19 files changed, 470 insertions(+), 42 deletions(-) create mode 100644 apps/web/tests/search-card.snapshot.ts create mode 100644 apps/web/tests/snapshots/search-card/grep-card.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml index 9edc74a0d2..179580e4d2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.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-30-web-search-card.md -2026-07-30-web-search-card.md: 1dff5ae5a4d789b1e57fcaef349959764583fbdf -2026-07-30-web-search-card.zh.md: 09d38066bf16923655b27a30c717d97ccbe434bb +2026-07-30-web-search-card.md: a3e3d7c3da1f686b4147e629fb4724d7750f8b6c +2026-07-30-web-search-card.zh.md: c333ebf434f2f6798c2e1758e4a534dc35ea2ef9 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md index 1dff5ae5a4..a3e3d7c3da 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -12,7 +12,7 @@ This is the follow-up the search render card note names: that PR was the backend ## Decision -`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, and a `card` value this client version does not know. +`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `kind` this version does not compile, and — because `kind` and the grouped/flat shape ride the same untrusted wire frame the host schema only string-checks — a known `kind` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card. @@ -22,7 +22,8 @@ The component's contract: - **Grouped matches, collapsible per file.** Each file is a header row (a bold path plus its match count, the whole row the collapse control) followed by its `lineNumber: line` rows. Collapsing a group drops its match rows from the flattened list and from the height cap's arithmetic, but never from the copy text. - **Flat path list.** The paths shape renders one path per row, no headers. -- **A capped indicator.** When `truncated`, the banner summary folds the pre-cap total in — `显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob — so the card never presents a capped page as the complete result; a reader who wants the rest follows the spill locator in the model-facing text, exactly as the model does. When not `truncated` the summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`). +- **A capped indicator.** When `truncated`, the banner summary folds the pre-cap total in — `显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob — so the card never presents a capped page as the complete result. When not `truncated` the summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`). +- **A recovery footer for a capped result.** The card holds only the retained page, but the locator to the rest — grep/glob's `Full … stored at: ` footer — lives only in the result view's `content` text, not in the structured matches/paths. Because every render site replaces the raw result with the card, `searchCardModel` surfaces that flattened `content` as `SearchCardModel.recovery` when (and only when) the result was capped, and each render site draws it below the card. Without this the one path to the dropped rows would vanish from the UI; an uncapped result carries every row, so its `content` adds nothing and is dropped. - **No soft wrapping.** Result rows are `white-space: pre` inside a horizontally scrolling box, so a long match line or a deep path scrolls sideways rather than folding. - **Height cap with an expand control.** More than `DEFAULT_SEARCH_MAX_LINES` (16) rows shows a head/tail slice with a button reporting the hidden count, the same shape and arithmetic as `TerminalBlock`. - **Copy.** The copy control writes the whole structured result — every file and match, or every path — regardless of the height cap or which groups are collapsed, so the clipboard carries the result rather than what the card happens to be showing. @@ -33,9 +34,9 @@ Geometry, radius, and fonts mirror `CodeBlock` and `TerminalBlock`, so a search Three sites consume the derivation, mirroring the terminal card's placement exactly: -- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.) -- **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card behind the row's expand toggle. -- **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, keeping the JSON Input section. +- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. A capped result's recovery footer sits below the card. Because the keyed row owns this render slot, a settled call with no search card — an errored search (grep/glob emit no result view on error), a successful nested `run_code` sub-dispatch (the backend computes no `presentationMeta`, so `resultView` is null), or a legacy generic result — would otherwise show only its summary with its content lost; the row surfaces that model-facing text as a fallback body, keyed on `search === null && settled` rather than on the error state alone. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.) +- **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card, with the recovery footer, behind the row's expand toggle. +- **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, with the recovery footer below it, keeping the JSON Input section. `CHAT_SEARCH_MAX_LINES` (8) is the row cap, half the primitive's default the panel keeps, for the same reason as `CHAT_TERMINAL_MAX_LINES`: the chat flow is a summary surface read across many calls, the panel is the single-call reading surface. @@ -55,7 +56,7 @@ Three sites consume the derivation, mirroring the terminal card's placement exac `packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. -`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, and each null arm (running, no views, generic, terminal, unknown card); the chat row's expand-gated matches and paths bodies through `GenericToolCard` against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` and a `glob` turn emitting `kind: 'paths'` as `resultView`, both truncated, driving the built-boot snapshot and the live `?fixture` server. +`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md index 09d38066bf..c333ebf434 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -12,7 +12,7 @@ Status: implemented ## Decision -`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图,以及本客户端版本不认识的 `card` 值。 +`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`kind` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `kind` 和分组/扁平形态与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `kind` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。 与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。 @@ -22,7 +22,8 @@ Status: implemented - **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。 - **扁平路径列表。** paths 形态每行一个路径,无头行。 -- **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果;想要其余部分的读者跟随面向模型文本里的溢出定位符,与模型的做法完全一致。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。 +- **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。 +- **被截断结果的恢复脚注。** 卡片只持有保留的那一页,但通往其余部分的定位符 —— grep/glob 的 `Full … stored at: ` 脚注 —— 只存在于结果视图的 `content` 文本里,而非结构化的 matches/paths 中。由于每个渲染点都用卡片替换了原始结果,`searchCardModel` 在(且仅在)结果被截断时把压平后的 `content` 作为 `SearchCardModel.recovery` 暴露出来,每个渲染点把它画在卡片下方。没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截断的结果携带了每一行,其 `content` 不增加任何信息,因此被丢弃。 - **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。 - **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。 - **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。 @@ -33,9 +34,9 @@ Status: implemented 三个渲染点消费该推导,与终端卡片的落位完全一致: -- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。) -- **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片。 -- **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,保留 JSON Input 段。 +- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。被截断结果的恢复脚注画在卡片下方。因为 keyed 行占据了这个渲染槽,一个没有搜索卡片的已结算调用 —— 出错的搜索(grep/glob 出错时不产出结果视图)、成功的嵌套 `run_code` 子派发(后端不为其计算 `presentationMeta`,故 `resultView` 为 null)、或旧日志的 generic 结果 —— 否则只会显示摘要而丢失内容;该行把这段面向模型的文本作为 fallback body 暴露出来,判据是 `search === null && 已结算`,而非仅凭错误状态。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。) +- **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片,并带恢复脚注。 +- **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,恢复脚注画在其下方,保留 JSON Input 段。 `CHAT_SEARCH_MAX_LINES`(8)是行内上限,为 primitive 默认值的一半(panel 保留默认值),理由与 `CHAT_TERMINAL_MAX_LINES` 相同:chat 流是跨多次调用扫读的摘要表面,panel 是单次调用的阅读表面。 @@ -55,7 +56,7 @@ Status: implemented `packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 -`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片);通过 `GenericToolCard` 的展开门控 matches 与 paths body,对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind,对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn 与一个发出 `kind: 'paths'` 的 `glob` turn 作为 `resultView`,两者都截断,驱动 built-boot snapshot 与实时 `?fixture` 服务。 +`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库契约要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按契约只测启动)无法捕获它。 ## Related diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts new file mode 100644 index 0000000000..7eca80d8bf --- /dev/null +++ b/apps/web/tests/search-card.snapshot.ts @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +// Assembled search-card snapshot: boots the real built `packages/client/*/lib/ +// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless +// FixtureApiClient transport (no API key, no model round), opens the fixture +// session, and pins the search card the `grep` turn (fixture turn 66) renders in +// the assembled application. The built-boot smoke proves the graph boots but +// carries no behavior assertions by contract; this is the assembled-output check +// that a broken SearchRow registration or a dropped card would fail — the +// per-package suites bench over src and cannot see the bundled wiring. +// +// Keyless and deterministic: the fixture is the fake server, so the grep turn's +// matches, its truncation summary, and its head/tail cap are fixed in the +// fixture, not harvested from a live model. The recovery-footer arm is a pure +// derivation over the result view, pinned at every render site by the +// ui-conversation suite; here the fixture turn exercises the assembled card +// shape and its cap. +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client' +import { AppWebEntry } from '@deepseek-ai/dsh-client-web' + +const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt') +const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh' + +const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { + id: '@deepseek-ai/dsh-client-ui-workspace', + dir: 'ui-workspace', + url: '/plugins/ui-workspace.js', + rev: 'fx', + inject: [ + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-sidebar', + ], + }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, +] + +const bundles = new Map(PLUGINS.map(plugin => [ + plugin.url, + readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'), +])) + +interface FixtureWindow extends Window { + __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] } + __ModuleLoader__?: unknown +} + +class ResizeObserverStub { + observe(): void {} + disconnect(): void {} + unobserve(): void {} +} + +const win = window as FixtureWindow +let unmount: (() => void) | undefined + +/** Normalize a rendered search card to a stable text shape: the kind, the banner + * summary, each file header (path + count), each visible match line, the expand + * control label, and the recovery footer. CSS-module class names carry a + * per-build hash in one of two schemes — ui-primitives emits `__` + * (name bounded by underscores), ui-conversation emits `_` (name at + * the end). `hasClass` matches a module class by its logical name under either, + * without matching a longer name that contains it (`line` must not hit + * `lineNumber`). */ +function hasClass(el: Element, name: string): boolean { + return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`)) +} + +function cardShape(root: Element): string { + const card = root.querySelector('[data-search]') + if (card === null) return '' + const pick = (from: Element, name: string): Element[] => + [...from.querySelectorAll('*')].filter(el => hasClass(el, name)) + const lines: string[] = [`kind=${card.getAttribute('data-search')}`] + const summary = pick(card, 'summary')[0]?.textContent?.trim() + if (summary !== undefined && summary !== '') lines.push(`summary=${summary}`) + for (const header of pick(card, 'fileHeader')) lines.push(`file=${header.textContent?.trim() ?? ''}`) + for (const row of pick(card, 'line')) lines.push(`line=${row.textContent?.trim() ?? ''}`) + const expand = pick(card, 'expand')[0]?.textContent?.trim() + if (expand !== undefined && expand !== '') lines.push(`expand=${expand}`) + const recovery = pick(root, 'searchRecovery')[0]?.textContent?.trim() + if (recovery !== undefined && recovery !== '') lines.push(`recovery=${recovery}`) + return lines.join('\n') +} + +beforeEach(() => { + localStorage.clear() + document.title = 'DeepSeek Harness' + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + setTimeout(() => { callback(0) }, 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) }) +}) + +afterEach(() => { + act(() => { unmount?.() }) + unmount = undefined + cleanup() + delete win.__DSH_BOOT__ + delete win.__ModuleLoader__ + document.body.innerHTML = '' + document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() }) + document.title = '' + history.replaceState(null, '', '/') + vi.unstubAllGlobals() +}) + +describe('assembled search card', () => { + it('renders the grep card, its truncation summary, and its capped head/tail slice from the built bundles', async () => { + history.replaceState(null, '', '/?fixture') + const root = document.createElement('div') + root.id = 'root' + document.body.appendChild(root) + win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) } + act(() => { + const entry = new AppWebEntry(root, { + fetchBundle: (url) => { + const code = bundles.get(url) + return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code) + }, + executeBundle: (code) => { (0, eval)(code) }, + }) + void entry.run() + unmount = () => { entry.dispose() } + }) + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + // Wait for chat content to reach the fixture's later turns (the bash sample + // is turn 65, the grep card turn 66). + await waitFor(() => { + expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() + }, { timeout: 10_000 }) + // The grep turn's keyed SearchRow renders the card resident: wait for it. + await waitFor(() => { + const tools = [...document.querySelectorAll('[data-tool]')].map(el => el.getAttribute('data-tool')) + expect(tools, `tools present: ${tools.join(', ')}`).toContain('grep') + }, { timeout: 10_000 }) + + // `data-tool` sits on the summary row; the card and recovery footer are its + // siblings inside the SearchRow wrapper, so shape the wrapper (its parent). + const grepRow = document.querySelector('[data-tool="grep"]')!.parentElement! + const shape = cardShape(grepRow) + if (refreshing) { + mkdirSync(dirname(EXPECTED), { recursive: true }) + writeFileSync(EXPECTED, shape) + } + await expect(shape).toMatchFileSnapshot(EXPECTED) + }) +}) diff --git a/apps/web/tests/snapshots/search-card/grep-card.expected.txt b/apps/web/tests/snapshots/search-card/grep-card.expected.txt new file mode 100644 index 0000000000..3d0efb3ecd --- /dev/null +++ b/apps/web/tests/snapshots/search-card/grep-card.expected.txt @@ -0,0 +1,11 @@ +kind=matches +summary=显示 9 / 共 42 处匹配 · 3 个文件 +file=packages/client/ui-primitives/src/SearchBlock.tsx3 +file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4 +line=16: export const DEFAULT_SEARCH_MAX_LINES = 16 +line=138: export function SearchBlock(props: SearchBlockProps) { +line=141: const [collapsed, setCollapsed] = useState>(() => new Set()) +line=73: const search = searchCardModel(block) +line=90: +line=113: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow) +expand=… 其余 4 行 \ No newline at end of file diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 64c4229e65..46a17332ae 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -159,6 +159,15 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin { lineNumber: 60, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' }, ], }, + { + path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx', + matches: [ + { lineNumber: 71, line: 'export function SearchRow({ toolName, block }: ToolRowProps) {' }, + { lineNumber: 73, line: ' const search = searchCardModel(block)' }, + { lineNumber: 90, line: ' ' }, + { lineNumber: 113, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)" }, + ], + }, ] /** @@ -169,7 +178,7 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin * `Line N:` rows, then a spill-recovery footer. */ const SEARCH_MATCHES_TEXT = [ - 'Found 5 of 42 matches', + 'Found 9 of 42 matches', '', ...SEARCH_MATCHES_FIXTURE.map(file => [file.path, ...file.matches.map(m => `Line ${m.lineNumber}: ${m.line}`)].join('\n')), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1588367646..c5b5d63e9f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: fc466190a744a1c13094ca6ebf62755d5bf49c98 -README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79 +README.md: e5e006b761020b8dfaf25eac191d1745326ae1f6 +README.zh.md: 3b19e2b5a0e9cc764bad74671e16c4428452e61f diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fc466190a7..e5e006b761 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,6 +16,8 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). +A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, carries the card resident below its summary; the render-site fallback keeps it behind the expand control. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) falls back to its flattened result text so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)). + Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f6fbff9c1e..3b19e2b5a0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,6 +14,8 @@ 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。 + 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index e046313128..a02608c11b 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -125,6 +125,17 @@ margin: 4px 0 4px 22px; } +/* The recovery footer for a capped search: the result text (its `Full … stored + at …` locator) below the card in the muted tone, since the card holds only the + retained rows. Same column indent as the card body. */ +.searchRecovery { + margin: 4px 0 4px 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} + /* Indented to the body's own column so the description reads as the card's heading rather than as another summary row, and sits tight against the card below it. Its own rule: grouping it with a body would put description diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 596ea0e1cd..37a6350a21 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -137,7 +137,16 @@ export function ToolRow({ {terminalBody !== null ? : searchBody !== null - ? + ? ( + <> + + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
{searchBody.recovery}
+ )} + + ) : variant === 'code' ? :
{text}
} diff --git a/packages/client/ui-conversation/src/client/contract/search-card-model.ts b/packages/client/ui-conversation/src/client/contract/search-card-model.ts index 9bb65a3092..f871e7d6dc 100644 --- a/packages/client/ui-conversation/src/client/contract/search-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/search-card-model.ts @@ -12,9 +12,15 @@ * therefore reads only `resultView` and returns null for a still-running call, * unlike the terminal card whose call view carries the command before * execution. + * + * A capped result also carries a recovery locator (grep/glob's `Full … stored + * at …` footer) that lives only in the view's `content` text, not in the + * structured matches/paths. Since both render sites replace the raw result with + * the card, this derivation surfaces that text as {@link SearchCardModel.recovery} + * so the one path to the dropped rows is not lost. * @module */ -import type { SearchBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallBlock } from './tool-call-model.ts' /** @@ -55,6 +61,54 @@ export interface SearchCardModel { * row then keeps its args-derived summary. */ title: string | undefined + /** + * The model-facing result text (the view's `content`, flattened), surfaced + * only when the search was capped. The card renders the retained matches or + * paths, but the recovery locator a capped result carries — grep/glob's + * `Full … stored at: ` footer, the one way to reach the rows the cap + * dropped — lives only in this text. A UI that replaces the raw result with + * the card would otherwise lose it. Absent when the result was not capped + * (the card holds every result) or the presenter supplied no content. + */ + recovery: string | undefined +} + +/** + * Whether every file group in a matches view is structurally valid: the wire + * frame carries `kind` and `card` as strings the host schema checks, but not the + * grouped shape, so a version mismatch or loose producer could deliver + * `kind: 'matches'` with a missing or malformed `files`. Rendering that would + * crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the + * generic path instead. + * @param files - the candidate `files` field off the untrusted result view. + * @returns whether `files` is a valid {@link SearchFileGroup} array. + */ +function isValidFiles(files: unknown): files is SearchFileGroup[] { + return Array.isArray(files) && files.every(file => + typeof file === 'object' && file !== null + && typeof (file as { path?: unknown }).path === 'string' + && Array.isArray((file as { matches?: unknown }).matches) + && (file as { matches: unknown[] }).matches.every(match => + typeof match === 'object' && match !== null + && typeof (match as { lineNumber?: unknown }).lineNumber === 'number' + && typeof (match as { line?: unknown }).line === 'string')) +} + +/** + * Flatten a result view's `content` blocks to their text, joined by newlines. + * The search views carry `content` (the model-facing result text) so a UI + * without a search card can show it; here it is the source of the truncation + * recovery footer. Non-text blocks (a search result carries none) are skipped. + * @param content - the result view's optional content blocks. + * @returns the joined text, or undefined when absent or empty. + */ +function flattenContent(content: readonly { type: string; text?: string }[] | undefined): string | undefined { + if (content === undefined) return undefined + const text = content + .filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string') + .map(block => block.text) + .join('\n') + return text === '' ? undefined : text } /** @@ -78,8 +132,17 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { const result = block.resultView?.card === 'search' ? block.resultView : null if (result === null) return null const common = { truncated: result.truncated, total: result.total } + // The recovery footer only matters when the tool capped the result: an + // uncapped card holds every match/path, so its content adds nothing the card + // does not already show. When capped, the content's `Full … stored at …` + // locator is the only path to the dropped rows, so surface it. + const recovery = result.truncated ? flattenContent(result.content) : undefined if (result.kind === 'matches') { - return { title: result.title, card: { kind: 'matches', files: result.files, ...common } } + // `files` rides the untrusted wire frame: the host schema checks `card`/`kind` + // strings but not the grouped shape, so validate it before SearchBlock, which + // would crash on a missing/malformed `files`. An invalid shape falls to generic. + if (!isValidFiles(result.files)) return null + return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } } } // `kind` rides the same untrusted wire frame as `card`, so a version mismatch // or a loose protocol producer could deliver a `card: 'search'` subtype this @@ -88,5 +151,8 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { // would leave SearchBlock calling `.length`/`.map` on an absent `paths`. // oxlint-disable-next-line typescript/no-unnecessary-condition -- kind is wire data; the compiled union cannot prove this exhaustive. if (result.kind !== 'paths') return null - return { title: result.title, card: { kind: 'paths', paths: result.paths, ...common } } + // `paths` is likewise unchecked by the wire schema; a known kind with a + // missing/malformed array would crash the paths card at `.map`. + if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null + return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } } } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index cb0c301c1b..1efca98969 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -107,3 +107,14 @@ .terminal { margin: 0; } + +/* The recovery footer for a capped search: the result text (its `Full … stored + at …` locator) below the card in the muted tone, since the card holds only the + retained rows. */ +.searchRecovery { + margin: 6px 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index e164d955b9..8cc14cb4a0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -130,8 +130,9 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * at the primitive's own full height allowance, so column-aligned output keeps * its alignment and scrolls sideways instead of folding. A search-card call — * a `grep`/`glob` result view — renders through the shared SearchBlock at the - * same full height allowance. Every other call, and a running call with no card - * yet, keeps the flattened text form. + * same full height allowance, with a capped search's recovery footer below it. + * Every other call, and a running call with no card yet, keeps the flattened + * text form. * @param props.material - the selected call's material from {@link materialFor}. * @param props.cwd - the session workspace root, resolving the terminal view's cwd. * @returns the Output section's body element. @@ -151,7 +152,18 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u ) } const search = searchCardModel(material.block) - if (search !== null) return + if (search !== null) { + return ( + <> + + {/* A capped search's recovery locator lives only in the result text; + show it below the card so the dropped rows stay reachable. */} + {search.recovery !== undefined && ( +
{search.recovery}
+ )} + + ) + } // A settled call always carries the result node the flattened form needs; // the running shape has no result to flatten. if (!('kind' in material.block)) return
运行中…
diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.module.css b/packages/client/ui-conversation/src/client/toolviews/search-row.module.css index dd0395ec1d..21908bd9e1 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.module.css @@ -104,3 +104,14 @@ font: var(--dsw-font-xs-13); color: var(--dsw-alias-state-error-primary); } + +/* The recovery footer for a capped search: the model-facing result text (its + `Full … stored at …` locator) shown below the card in the muted tone, since + the card holds only the retained rows. Same column indent as the card body. */ +.recovery { + margin: 4px 0 4px 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx index 0726f30a3c..8c0181ba78 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx @@ -42,11 +42,14 @@ function stateStatus(state: ToolRowState): string | null { /** * A settled result's text, flattened from its content blocks, for the arm that - * shows a failure the search card cannot: grep/glob have no `presentResult` on - * an error result, so an errored search has no card, and the keyed row is not a - * details-panel target. Without this the failure — a bad pattern, a missing - * path, a nested run_code dispatch that returned no card — would read as a bare - * red dot with the model-facing error text nowhere on screen. + * shows a result the search card cannot. Two cases reach it: an errored search + * (grep/glob emit no `presentResult` on an error result, so an errored search + * has no card), and a settled call whose result view is not a search card at all + * — a nested `run_code` sub-dispatch (the backend computes no presentationMeta + * for it, so `resultView` is null) or a legacy generic result. In both the keyed + * SearchRow owns the render slot, so without this arm the model-facing text would + * have nowhere to go: an errored search would read as a bare red dot, and a + * successful cardless result would show only its summary with its content lost. * @param block - the frozen call slice. * @returns the result text, or null for a running call or an empty result. */ @@ -63,18 +66,24 @@ function errorText(block: ToolRowProps['block']): string | null { /** * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the - * completed search's card resident below it. The summary row is not a - * details-panel control, so the card's copy, per-file collapse, and expand - * controls are the row's only interactions. Registered under both `grep` and - * `glob`; the derived model's `kind` decides the card shape. + * completed search's card resident below it, and — when the result was capped — + * the recovery footer below the card. The summary row is not a details-panel + * control, so the card's copy, per-file collapse, and expand controls are the + * row's only interactions. Registered under both `grep` and `glob`; the derived + * model's `kind` decides the card shape. */ export function SearchRow({ toolName, block }: ToolRowProps) { const model = toolRowModel(toolName, block) const search = searchCardModel(block) const status = stateStatus(model.state) - // An errored search has no card (grep/glob return no presentResult on error); - // surface its result text so the failure is more than a red dot. - const failure = search === null && model.state === 'error' ? errorText(block) : null + // A settled call with no search card — an errored search (grep/glob emit no + // result view on error), a successful nested run_code sub-dispatch, or a + // legacy generic result — has its model-facing text nowhere else to go, since + // the keyed SearchRow owns this render slot. Surface it as the fallback body. + // A running call ('kind' absent) has no result to flatten; errorText returns + // null for it, so the arm stays closed until settle. + const settled = 'kind' in block + const fallback = search === null && settled ? errorText(block) : null return (
@@ -89,7 +98,11 @@ export function SearchRow({ toolName, block }: ToolRowProps) { {search !== null && ( )} - {failure !== null &&
{failure}
} + {/* A capped search drops rows from the card; its recovery locator (the + `Full … stored at …` footer) lives only in the result text, so show it + below the card so the one path to the dropped rows survives. */} + {search?.recovery !== undefined &&
{search.recovery}
} + {fallback !== null &&
{fallback}
}
) } diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index 6d566b1b01..26eb16a7f9 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -77,6 +77,7 @@ describe('searchCardModel', () => { it('derives a matches card from the grep result view', () => { expect(searchCardModel(settledGrep())).toEqual({ title: undefined, + recovery: undefined, card: { kind: 'matches', files: [ @@ -91,6 +92,7 @@ describe('searchCardModel', () => { it('derives a paths card from the glob result view, carrying the truncation signal', () => { expect(searchCardModel(settledGlob({ resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({ title: undefined, + recovery: undefined, card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 }, }) }) @@ -115,6 +117,55 @@ describe('searchCardModel', () => { const future = { card: 'chart' } as unknown as ToolResultView expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull() }) + + it('returns null for a card:search view whose kind this version does not compile', () => { + // `kind` rides the same untrusted wire frame as `card`; a subtype this client + // does not know must fall to the generic path, never render as a paths card + // that would crash SearchBlock on an absent `paths`. + const futureKind = { + card: 'search', kind: 'future', truncated: false, total: 0, + } as unknown as ToolResultView + expect(searchCardModel(settledGrep({ resultView: futureKind }))).toBeNull() + }) + + it('returns null for a known kind whose structured shape is missing or malformed', () => { + // The host wire schema checks the `card`/`kind` strings but not the grouped + // shape, so a version mismatch could deliver kind:'matches' with no `files` + // (or kind:'paths' with no `paths`). Rendering that crashes SearchBlock at + // `.reduce`/`.map`; the derivation drops to the generic path instead. + const noFiles = { card: 'search', kind: 'matches', truncated: false, total: 0 } as unknown as ToolResultView + expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull() + const badFile = { + card: 'search', kind: 'matches', truncated: false, total: 1, + files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }], + } as unknown as ToolResultView + expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull() + const noPaths = { card: 'search', kind: 'paths', truncated: false, total: 0 } as unknown as ToolResultView + expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull() + const badPaths = { + card: 'search', kind: 'paths', truncated: false, total: 1, paths: [42], + } as unknown as ToolResultView + expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull() + }) + + it('surfaces the recovery text only when the result was capped', () => { + const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' + // Capped: the content (its `Full … stored at …` locator) rides through so the + // dropped rows stay reachable. + const capped = searchCardModel(settledGrep({ + resultView: resultMatches({ truncated: true, total: 42, content: [{ type: 'text', text: recovery }] }), + })) + expect(capped?.recovery).toBe(recovery) + // Not capped: the card holds every match, so the content adds nothing and is + // dropped. + const whole = searchCardModel(settledGrep({ + resultView: resultMatches({ truncated: false, content: [{ type: 'text', text: recovery }] }), + })) + expect(whole?.recovery).toBeUndefined() + // Capped but the presenter attached no content: nothing to surface. + const noContent = searchCardModel(settledGrep({ resultView: resultMatches({ truncated: true, total: 42 }) })) + expect(noContent?.recovery).toBeUndefined() + }) }) describe('chat row search body (GenericToolCard fallback)', () => { @@ -150,6 +201,16 @@ describe('chat row search body (GenericToolCard fallback)', () => { expect(view.getByText(/"pattern"/)).toBeTruthy() expect(searchKindOf(view.container)).toBeNull() }) + + it('the expanded body shows the recovery footer below a capped card', () => { + const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(searchKindOf(view.container)).toBe('matches') + expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy() + }) }) describe('SearchRow keyed card', () => { @@ -195,6 +256,34 @@ describe('SearchRow keyed card', () => { expect(view.getByText('grep: invalid regular expression')).toBeTruthy() }) + it('surfaces the result text for a settled non-error call with no card', () => { + // A successful nested run_code sub-dispatch (backend computes no + // presentationMeta, so resultView is null) or a legacy generic result settles + // with search === null and state ok. The keyed SearchRow owns the slot, so + // without the widened arm the content would be lost behind a bare summary. + const view = render() + expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok') + expect(searchKindOf(view.container)).toBeNull() + expect(view.getByText('nested run_code output line')).toBeTruthy() + }) + + it('renders the recovery footer below the card when the search was capped', () => { + const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' + const view = render() + expect(searchKindOf(view.container)).toBe('matches') + expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy() + }) + + it('shows no recovery footer for an uncapped search', () => { + const view = render() + expect(view.container.textContent).not.toMatch(/stored at/) + }) + it('falls back to the error name/code when an errored result has no text block', () => { const view = render( { expect(searchKindOf(view.container)).toBe('paths') }) + it('renders the recovery footer below the card for a capped search', () => { + const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)' + const view = mount(snapshot({ + nodes: [settledGlob({ resultView: resultPaths({ truncated: true, total: 23, content: [{ type: 'text', text: recovery }] }) })], + }), globTarget) + expect(searchKindOf(view.container)).toBe('paths') + expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy() + }) + it('a non-search result keeps the flattened pre form', () => { const view = mount(snapshot({ nodes: [settledGrep({ callView: null, resultView: null })], diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx index 843b947491..5210b2fdc6 100644 --- a/packages/client/ui-primitives/src/SearchBlock.tsx +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -209,17 +209,23 @@ export function SearchBlock(props: SearchBlockProps) { const headLines = Math.ceil(maxLines / 2) const tailLines = maxLines - headLines const head = capped ? rows.slice(0, headLines) : rows - const tail = capped ? rows.slice(rows.length - tailLines) : [] + const naturalTail = capped ? rows.slice(rows.length - tailLines) : [] // When the tail slice begins inside a file's matches, its own header sits // above the cut and is not shown, so those rows could not be attributed to a // file. Restore the owning header at the top of the tail — unless the head // slice already carries it (a single large file), where it would duplicate. - const tailLead = tail[0] + const tailLead = naturalTail[0] const tailHeader = tailLead?.type === 'match' && !head.some(row => row.type === 'file' && row.index === tailLead.fileIndex) ? rows.find((row): row is Extract => row.type === 'file' && row.index === tailLead.fileIndex) : undefined + // The restored header is itself a row. Left extra it would push the card to + // maxLines + 1 and overstate `hidden` by one, so it consumes a tail slot: drop + // the tail's first row (the match whose header this is) for it. Visible rows + // hold at maxLines and `hidden` stays exact; the dropped match joins the + // hidden middle. + const tail = tailHeader === undefined ? naturalTail : naturalTail.slice(1) const renderRow = (row: SearchRow): ReactNode => { if (row.type === 'path') return
{row.path}
diff --git a/packages/client/ui-primitives/tests/search-block.spec.tsx b/packages/client/ui-primitives/tests/search-block.spec.tsx index 45a6664924..29cf87fb91 100644 --- a/packages/client/ui-primitives/tests/search-block.spec.tsx +++ b/packages/client/ui-primitives/tests/search-block.spec.tsx @@ -140,16 +140,20 @@ describe('SearchBlock height cap', () => { it('restores the owning file header above a tail slice that begins mid-file', () => { // Two files of 10 matches each → 22 rows. Cap 8: head 4 (a.ts header + 3 - // matches), tail 4 (last 4 of b.ts, whose header sits above the cut). + // matches), tail 4. The tail begins mid-b.ts, so its header is restored — + // and, being a row itself, it consumes one tail slot rather than pushing the + // card to 9 rows: the tail keeps its last 3 matches, total visible = 8. const view = render() - // The tail's own header is restored so its rows can be attributed to b.ts. expect(fileHeaders(view.container)).toEqual(['a.ts10', 'b.ts10']) expect(lines(view.container)).toEqual([ '1: hit 1', '2: hit 2', '3: hit 3', - '17: hit 17', '18: hit 18', '19: hit 19', '20: hit 20', + '18: hit 18', '19: hit 19', '20: hit 20', ]) + // Visible rows hold at maxLines (2 headers + 6 matches = 8), so the hidden + // count stays exact: 22 − 8 = 14. + expect(view.getByRole('button', { name: '展开其余 14 行结果' })).toBeTruthy() }) it('caps at the documented default when maxLines is absent', () => { From 4a4ec6fd4d3f1a36d5f60726befdc49beb2621d6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 22:49:41 +0800 Subject: [PATCH 38/47] fix(web-search-card): follow base rename kind->shape and view-drops-content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base (feat/search-presenter) renamed the search result view's discriminant from `kind` to `shape` and removed the view's `content` field (a UI without a card now falls back to the raw tool/result content). Adapt the web consumer: - searchCardModel switches on `result.shape`; SearchBlock's own `kind` prop is mapped from it. - The truncation recovery footer reads the block's raw `content` (where the `Full … stored at …` locator now lives) instead of the removed view content. - Fixture grep/glob views use `shape` and drop `content`; the recovery footer rides the raw tool/result text. - Tests and the bilingual Agent Note follow the rename and the recovery source. --- .../2026-07-30-web-search-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-search-card.md | 6 +- .../feature/2026-07-30-web-search-card.zh.md | 6 +- .../client/connection/src/client/fixture.ts | 18 ++--- .../src/client/contract/search-card-model.ts | 65 ++++++++++--------- .../tests/search-card.spec.tsx | 65 ++++++++++--------- 6 files changed, 83 insertions(+), 81 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml index 179580e4d2..a6658971b1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.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-30-web-search-card.md -2026-07-30-web-search-card.md: a3e3d7c3da1f686b4147e629fb4724d7750f8b6c -2026-07-30-web-search-card.zh.md: c333ebf434f2f6798c2e1758e4a534dc35ea2ef9 +2026-07-30-web-search-card.md: 4c7ae6c8c658f4f10f0667b12853cb2e70df15b1 +2026-07-30-web-search-card.zh.md: 714a2979730dc2c83f6cfc1cf6d21978755a2d95 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md index a3e3d7c3da..4c7ae6c8c6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -6,13 +6,13 @@ English | [中文](2026-07-30-web-search-card.zh.md) ## Problem -The `grep` and `glob` tools declare a result-time `card: 'search'` render intent ([search render card](2026-07-30-search-render-card.md)): a `SearchMatchesResultView` (`kind: 'matches'`) carrying grep's matches grouped by file, or a `SearchPathsResultView` (`kind: 'paths'`) carrying glob's flat path list, both with a `truncated`/`total` capping signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: every non-terminal, non-diff tool result fell through to the generic card, which renders the model-facing text. A web frontend that wants an expandable per-file group of matches, or a scannable path list, had only the pre-formatted text. +The `grep` and `glob` tools declare a result-time `card: 'search'` render intent ([search render card](2026-07-30-search-render-card.md)): a `SearchMatchesResultView` (`shape: 'matches'`) carrying grep's matches grouped by file, or a `SearchPathsResultView` (`shape: 'paths'`) carrying glob's flat path list, both with a `truncated`/`total` capping signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: every non-terminal, non-diff tool result fell through to the generic card, which renders the model-facing text. A web frontend that wants an expandable per-file group of matches, or a scannable path list, had only the pre-formatted text. This is the follow-up the search render card note names: that PR was the backend contract and its two producers; this PR is the web consumer. ## Decision -`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `kind` this version does not compile, and — because `kind` and the grouped/flat shape ride the same untrusted wire frame the host schema only string-checks — a known `kind` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). +`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation. The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card. @@ -23,7 +23,7 @@ The component's contract: - **Grouped matches, collapsible per file.** Each file is a header row (a bold path plus its match count, the whole row the collapse control) followed by its `lineNumber: line` rows. Collapsing a group drops its match rows from the flattened list and from the height cap's arithmetic, but never from the copy text. - **Flat path list.** The paths shape renders one path per row, no headers. - **A capped indicator.** When `truncated`, the banner summary folds the pre-cap total in — `显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob — so the card never presents a capped page as the complete result. When not `truncated` the summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`). -- **A recovery footer for a capped result.** The card holds only the retained page, but the locator to the rest — grep/glob's `Full … stored at: ` footer — lives only in the result view's `content` text, not in the structured matches/paths. Because every render site replaces the raw result with the card, `searchCardModel` surfaces that flattened `content` as `SearchCardModel.recovery` when (and only when) the result was capped, and each render site draws it below the card. Without this the one path to the dropped rows would vanish from the UI; an uncapped result carries every row, so its `content` adds nothing and is dropped. +- **A recovery footer for a capped result.** The card holds only the retained page, but the locator to the rest — grep/glob's `Full … stored at: ` footer — lives only in the raw `tool/result` content (the search view carries no result text; a UI without a card falls back to that raw content), not in the structured matches/paths. Because every render site replaces the raw result with the card, `searchCardModel` surfaces the block's own flattened result text as `SearchCardModel.recovery` when (and only when) the result was capped, and each render site draws it below the card. Without this the one path to the dropped rows would vanish from the UI; an uncapped result carries every row, so its raw text adds nothing and is dropped. - **No soft wrapping.** Result rows are `white-space: pre` inside a horizontally scrolling box, so a long match line or a deep path scrolls sideways rather than folding. - **Height cap with an expand control.** More than `DEFAULT_SEARCH_MAX_LINES` (16) rows shows a head/tail slice with a button reporting the hidden count, the same shape and arithmetic as `TerminalBlock`. - **Copy.** The copy control writes the whole structured result — every file and match, or every path — regardless of the height cap or which groups are collapsed, so the clipboard carries the result rather than what the card happens to be showing. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md index c333ebf434..714a297973 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -6,13 +6,13 @@ Status: implemented ## Problem -`grep` 与 `glob` 工具声明了一个仅在结果阶段存在的 `card: 'search'` render intent([search render card](2026-07-30-search-render-card.md)):`SearchMatchesResultView`(`kind: 'matches'`)携带 grep 按文件分组的匹配,或 `SearchPathsResultView`(`kind: 'paths'`)携带 glob 的扁平路径列表,两者都带 `truncated`/`total` 截断信号。该视图已经到达浏览器 —— host、connection、runtime 把它作为 `resultView` 投递到 `ConversationSnapshot` 上 —— 但 Web 客户端忽略了它:每个非终端、非 diff 的工具结果都落到 generic 卡片,渲染面向模型的文本。想把搜索结果渲染成可展开的按文件匹配分组、或可扫读的路径列表的 web 前端,只有那段预格式化文本。 +`grep` 与 `glob` 工具声明了一个仅在结果阶段存在的 `card: 'search'` render intent([search render card](2026-07-30-search-render-card.md)):`SearchMatchesResultView`(`shape: 'matches'`)携带 grep 按文件分组的匹配,或 `SearchPathsResultView`(`shape: 'paths'`)携带 glob 的扁平路径列表,两者都带 `truncated`/`total` 截断信号。该视图已经到达浏览器 —— host、connection、runtime 把它作为 `resultView` 投递到 `ConversationSnapshot` 上 —— 但 Web 客户端忽略了它:每个非终端、非 diff 的工具结果都落到 generic 卡片,渲染面向模型的文本。想把搜索结果渲染成可展开的按文件匹配分组、或可扫读的路径列表的 web 前端,只有那段预格式化文本。 这正是 search render card note 指名的后续:那个 PR 是后端契约和它的两个生产者,本 PR 是 web 消费者。 ## Decision -`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`kind` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `kind` 和分组/扁平形态与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `kind` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。 +`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`shape` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `shape` 和分组/扁平内容与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `shape` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(不是 `kind` —— 后端把 `kind` 留给 call view 的选图标签);`SearchBlock` 自身的 prop 仍是 `kind`,由本推导从 `shape` 映射得到。 与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。 @@ -23,7 +23,7 @@ Status: implemented - **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。 - **扁平路径列表。** paths 形态每行一个路径,无头行。 - **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。 -- **被截断结果的恢复脚注。** 卡片只持有保留的那一页,但通往其余部分的定位符 —— grep/glob 的 `Full … stored at: ` 脚注 —— 只存在于结果视图的 `content` 文本里,而非结构化的 matches/paths 中。由于每个渲染点都用卡片替换了原始结果,`searchCardModel` 在(且仅在)结果被截断时把压平后的 `content` 作为 `SearchCardModel.recovery` 暴露出来,每个渲染点把它画在卡片下方。没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截断的结果携带了每一行,其 `content` 不增加任何信息,因此被丢弃。 +- **被截断结果的恢复脚注。** 卡片只持有保留的那一页,但通往其余部分的定位符 —— grep/glob 的 `Full … stored at: ` 脚注 —— 只存在于原始 `tool/result` 内容里(搜索视图不携带结果文本;没有卡片的 UI 回退到那段原始内容),而非结构化的 matches/paths 中。由于每个渲染点都用卡片替换了原始结果,`searchCardModel` 在(且仅在)结果被截断时把 block 自身压平后的结果文本作为 `SearchCardModel.recovery` 暴露出来,每个渲染点把它画在卡片下方。没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截断的结果携带了每一行,其原始文本不增加任何信息,因此被丢弃。 - **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。 - **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。 - **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 46a17332ae..f41a6ed2f7 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -435,20 +435,16 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR const call = presentCall(name, argsRaw) if (call === undefined) return undefined // Search is result-time only: the call stays a generic search card, and the - // result view carries the structured shape the card renders, with the - // model-facing text as `content` for a UI without a search card. `total` - // exceeds the retained count so the card shows its capped indicator. + // result view carries the structured shape the card renders. The view holds no + // result text — a UI without a search card falls back to the raw tool/result + // content — so the truncation recovery footer rides that raw content (the + // `toolTurn` message text), not the view. `total` exceeds the retained count so + // the card shows its capped indicator. if (name === 'grep') { - return { - card: 'search', kind: 'matches', files: SEARCH_MATCHES_FIXTURE, - truncated: true, total: 42, content: text(resultText), - } + return { card: 'search', shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 } } if (name === 'glob') { - return { - card: 'search', kind: 'paths', paths: SEARCH_PATHS_FIXTURE, - truncated: true, total: 23, content: text(resultText), - } + return { card: 'search', shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 } } switch (call.card) { case 'terminal': diff --git a/packages/client/ui-conversation/src/client/contract/search-card-model.ts b/packages/client/ui-conversation/src/client/contract/search-card-model.ts index f871e7d6dc..08d6389686 100644 --- a/packages/client/ui-conversation/src/client/contract/search-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/search-card-model.ts @@ -14,10 +14,11 @@ * execution. * * A capped result also carries a recovery locator (grep/glob's `Full … stored - * at …` footer) that lives only in the view's `content` text, not in the - * structured matches/paths. Since both render sites replace the raw result with - * the card, this derivation surfaces that text as {@link SearchCardModel.recovery} - * so the one path to the dropped rows is not lost. + * at …` footer) in the raw `tool/result` content, not in the structured + * matches/paths the view carries. Since both render sites replace that raw + * result with the card, this derivation surfaces the block's own result text as + * {@link SearchCardModel.recovery} so the one path to the dropped rows is not + * lost. * @module */ import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives' @@ -62,22 +63,22 @@ export interface SearchCardModel { */ title: string | undefined /** - * The model-facing result text (the view's `content`, flattened), surfaced - * only when the search was capped. The card renders the retained matches or - * paths, but the recovery locator a capped result carries — grep/glob's - * `Full … stored at: ` footer, the one way to reach the rows the cap - * dropped — lives only in this text. A UI that replaces the raw result with - * the card would otherwise lose it. Absent when the result was not capped - * (the card holds every result) or the presenter supplied no content. + * The raw `tool/result` text, flattened, surfaced only when the search was + * capped. The card renders the retained matches or paths, but the recovery + * locator a capped result carries — grep/glob's `Full … stored at: ` + * footer, the one way to reach the rows the cap dropped — lives only in the raw + * result text, which the card replaces. A UI that shows the card would + * otherwise lose it. Absent when the result was not capped (the card holds + * every result) or the block carries no text. */ recovery: string | undefined } /** * Whether every file group in a matches view is structurally valid: the wire - * frame carries `kind` and `card` as strings the host schema checks, but not the + * frame carries `shape` and `card` as strings the host schema checks, but not the * grouped shape, so a version mismatch or loose producer could deliver - * `kind: 'matches'` with a missing or malformed `files`. Rendering that would + * `shape: 'matches'` with a missing or malformed `files`. Rendering that would * crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the * generic path instead. * @param files - the candidate `files` field off the untrusted result view. @@ -95,15 +96,15 @@ function isValidFiles(files: unknown): files is SearchFileGroup[] { } /** - * Flatten a result view's `content` blocks to their text, joined by newlines. - * The search views carry `content` (the model-facing result text) so a UI - * without a search card can show it; here it is the source of the truncation - * recovery footer. Non-text blocks (a search result carries none) are skipped. - * @param content - the result view's optional content blocks. - * @returns the joined text, or undefined when absent or empty. + * Flatten a settled tool result's content blocks to their text, joined by + * newlines. The search view carries no result text — a UI without a card falls + * back to the raw `tool/result` content — so the truncation recovery footer is + * read from the block's own content here. Non-text blocks (a search result + * carries none) are skipped. + * @param content - the result node's content blocks. + * @returns the joined text, or undefined when empty. */ -function flattenContent(content: readonly { type: string; text?: string }[] | undefined): string | undefined { - if (content === undefined) return undefined +function flattenContent(content: readonly { type: string; text?: string }[]): string | undefined { const text = content .filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string') .map(block => block.text) @@ -119,7 +120,7 @@ function flattenContent(content: readonly { type: string; text?: string }[] | un * a still-running call (no result view) is null, as is a settled call whose * result view is not a search card — including a `card` value this UI version * does not know, which arrives over the wire and cannot be trusted to be one of - * the compiled variants, a `card: 'search'` view whose `kind` is neither + * the compiled variants, a `card: 'search'` view whose `shape` is neither * `matches` nor `paths` (equally untrusted wire data), and a generic result a * `grep`/`glob` failure or nested `run_code` dispatch produces (its text keeps * the generic path). @@ -133,25 +134,25 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { if (result === null) return null const common = { truncated: result.truncated, total: result.total } // The recovery footer only matters when the tool capped the result: an - // uncapped card holds every match/path, so its content adds nothing the card - // does not already show. When capped, the content's `Full … stored at …` + // uncapped card holds every match/path, so the raw text adds nothing the card + // does not already show. When capped, the raw result's `Full … stored at …` // locator is the only path to the dropped rows, so surface it. - const recovery = result.truncated ? flattenContent(result.content) : undefined - if (result.kind === 'matches') { - // `files` rides the untrusted wire frame: the host schema checks `card`/`kind` + const recovery = result.truncated ? flattenContent(block.content) : undefined + if (result.shape === 'matches') { + // `files` rides the untrusted wire frame: the host schema checks `card`/`shape` // strings but not the grouped shape, so validate it before SearchBlock, which // would crash on a missing/malformed `files`. An invalid shape falls to generic. if (!isValidFiles(result.files)) return null return { title: result.title, recovery, card: { kind: 'matches', files: result.files, ...common } } } - // `kind` rides the same untrusted wire frame as `card`, so a version mismatch + // `shape` rides the same untrusted wire frame as `card`, so a version mismatch // or a loose protocol producer could deliver a `card: 'search'` subtype this - // client does not compile. Guard the paths shape explicitly: an unknown kind + // client does not compile. Guard the paths shape explicitly: an unknown shape // falls to the generic path rather than being rendered as a paths card, which // would leave SearchBlock calling `.length`/`.map` on an absent `paths`. - // oxlint-disable-next-line typescript/no-unnecessary-condition -- kind is wire data; the compiled union cannot prove this exhaustive. - if (result.kind !== 'paths') return null - // `paths` is likewise unchecked by the wire schema; a known kind with a + // oxlint-disable-next-line typescript/no-unnecessary-condition -- shape is wire data; the compiled union cannot prove this exhaustive. + if (result.shape !== 'paths') return null + // `paths` is likewise unchecked by the wire schema; a known shape with a // missing/malformed array would crash the paths card at `.map`. if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } } diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index 26eb16a7f9..922d6db848 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -38,8 +38,8 @@ const GREP_ARGS = '{"pattern":"foo","path":"src"}' const GLOB_ARGS = '{"pattern":"**/*.ts","path":"src"}' /** A grep result view: matches grouped by file. */ -const resultMatches = (over?: Partial>): ToolResultView => ({ - card: 'search', kind: 'matches', +const resultMatches = (over?: Partial>): ToolResultView => ({ + card: 'search', shape: 'matches', files: [ { path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] }, { path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] }, @@ -48,8 +48,8 @@ const resultMatches = (over?: Partial>): ToolResultView => ({ - card: 'search', kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over, +const resultPaths = (over?: Partial>): ToolResultView => ({ + card: 'search', shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over, }) const runningGrep = (over?: Partial): RunningToolCall => ({ @@ -90,7 +90,8 @@ describe('searchCardModel', () => { }) it('derives a paths card from the glob result view, carrying the truncation signal', () => { - expect(searchCardModel(settledGlob({ resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({ + // Empty block content isolates the truncation signal from the recovery arm. + expect(searchCardModel(settledGlob({ content: [], resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({ title: undefined, recovery: undefined, card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 }, @@ -118,53 +119,55 @@ describe('searchCardModel', () => { expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull() }) - it('returns null for a card:search view whose kind this version does not compile', () => { - // `kind` rides the same untrusted wire frame as `card`; a subtype this client + it('returns null for a card:search view whose shape this version does not compile', () => { + // `shape` rides the same untrusted wire frame as `card`; a subtype this client // does not know must fall to the generic path, never render as a paths card // that would crash SearchBlock on an absent `paths`. - const futureKind = { - card: 'search', kind: 'future', truncated: false, total: 0, + const futureShape = { + card: 'search', shape: 'future', truncated: false, total: 0, } as unknown as ToolResultView - expect(searchCardModel(settledGrep({ resultView: futureKind }))).toBeNull() + expect(searchCardModel(settledGrep({ resultView: futureShape }))).toBeNull() }) - it('returns null for a known kind whose structured shape is missing or malformed', () => { - // The host wire schema checks the `card`/`kind` strings but not the grouped - // shape, so a version mismatch could deliver kind:'matches' with no `files` - // (or kind:'paths' with no `paths`). Rendering that crashes SearchBlock at + it('returns null for a known shape whose structured shape is missing or malformed', () => { + // The host wire schema checks the `card`/`shape` strings but not the grouped + // shape, so a version mismatch could deliver shape:'matches' with no `files` + // (or shape:'paths' with no `paths`). Rendering that crashes SearchBlock at // `.reduce`/`.map`; the derivation drops to the generic path instead. - const noFiles = { card: 'search', kind: 'matches', truncated: false, total: 0 } as unknown as ToolResultView + const noFiles = { card: 'search', shape: 'matches', truncated: false, total: 0 } as unknown as ToolResultView expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull() const badFile = { - card: 'search', kind: 'matches', truncated: false, total: 1, + card: 'search', shape: 'matches', truncated: false, total: 1, files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }], } as unknown as ToolResultView expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull() - const noPaths = { card: 'search', kind: 'paths', truncated: false, total: 0 } as unknown as ToolResultView + const noPaths = { card: 'search', shape: 'paths', truncated: false, total: 0 } as unknown as ToolResultView expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull() const badPaths = { - card: 'search', kind: 'paths', truncated: false, total: 1, paths: [42], + card: 'search', shape: 'paths', truncated: false, total: 1, paths: [42], } as unknown as ToolResultView expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull() }) it('surfaces the recovery text only when the result was capped', () => { const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' - // Capped: the content (its `Full … stored at …` locator) rides through so the - // dropped rows stay reachable. + // The recovery locator lives in the raw tool/result content (the view carries + // no text), surfaced only when the card capped the result. const capped = searchCardModel(settledGrep({ - resultView: resultMatches({ truncated: true, total: 42, content: [{ type: 'text', text: recovery }] }), + content: [{ type: 'text', text: recovery }], + resultView: resultMatches({ truncated: true, total: 42 }), })) expect(capped?.recovery).toBe(recovery) - // Not capped: the card holds every match, so the content adds nothing and is - // dropped. + // Not capped: the card holds every match, so the raw content adds nothing and + // is dropped. const whole = searchCardModel(settledGrep({ - resultView: resultMatches({ truncated: false, content: [{ type: 'text', text: recovery }] }), + content: [{ type: 'text', text: recovery }], + resultView: resultMatches({ truncated: false }), })) expect(whole?.recovery).toBeUndefined() - // Capped but the presenter attached no content: nothing to surface. - const noContent = searchCardModel(settledGrep({ resultView: resultMatches({ truncated: true, total: 42 }) })) - expect(noContent?.recovery).toBeUndefined() + // Capped but the block carries no text: nothing to surface. + const noText = searchCardModel(settledGrep({ content: [], resultView: resultMatches({ truncated: true, total: 42 }) })) + expect(noText?.recovery).toBeUndefined() }) }) @@ -205,7 +208,8 @@ describe('chat row search body (GenericToolCard fallback)', () => { it('the expanded body shows the recovery footer below a capped card', () => { const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' const view = render() fireEvent.click(view.container.querySelector('button')!) expect(searchKindOf(view.container)).toBe('matches') @@ -273,7 +277,8 @@ describe('SearchRow keyed card', () => { it('renders the recovery footer below the card when the search was capped', () => { const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)' const view = render() expect(searchKindOf(view.container)).toBe('matches') expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy() @@ -376,7 +381,7 @@ describe('DetailsPanel Output section (search)', () => { it('renders the recovery footer below the card for a capped search', () => { const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)' const view = mount(snapshot({ - nodes: [settledGlob({ resultView: resultPaths({ truncated: true, total: 23, content: [{ type: 'text', text: recovery }] }) })], + nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], resultView: resultPaths({ truncated: true, total: 23 }) })], }), globTarget) expect(searchKindOf(view.container)).toBe('paths') expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy() From e1408c2a44adb07848b102405acc643d1ac8b0db Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 23:09:12 +0800 Subject: [PATCH 39/47] refactor(ui-primitives): extract shared head/tail cap and copy-feedback helpers The tail-header cap fix pushed SearchBlock's head/tail slicing arithmetic and its copy-feedback hook over the duplication gate's threshold against the byte-identical logic in TerminalBlock. Extract both into head-tail-cap.ts (headTailCap) and use-copy-feedback.ts (useCopyFeedback) and consume them from both blocks, deleting the clone rather than nudging it under the limit. --- .../client/ui-primitives/src/SearchBlock.tsx | 22 ++--------- .../ui-primitives/src/TerminalBlock.tsx | 25 +++---------- .../client/ui-primitives/src/head-tail-cap.ts | 33 +++++++++++++++++ .../ui-primitives/src/use-copy-feedback.ts | 37 +++++++++++++++++++ 4 files changed, 80 insertions(+), 37 deletions(-) create mode 100644 packages/client/ui-primitives/src/head-tail-cap.ts create mode 100644 packages/client/ui-primitives/src/use-copy-feedback.ts diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx index 5210b2fdc6..463a3bfa26 100644 --- a/packages/client/ui-primitives/src/SearchBlock.tsx +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -10,7 +10,8 @@ import { useCallback, useState, type ReactNode } from 'react' import clsx from 'clsx' -import { writeClipboard } from './clipboard.ts' +import { headTailCap } from './head-tail-cap.ts' +import { useCopyFeedback } from './use-copy-feedback.ts' import css from './SearchBlock.module.css' /** @@ -173,23 +174,13 @@ export function SearchBlock(props: SearchBlockProps) { const { truncated, total, maxLines = DEFAULT_SEARCH_MAX_LINES, className } = props const [expanded, setExpanded] = useState(false) const [collapsed, setCollapsed] = useState>(() => new Set()) - const [copied, setCopied] = useState(false) // `props` is a fresh object each render, so memoizing on it never hits; the // flatten is cheap, so it runs inline keyed on the collapse set instead. const rows = toRows(props, collapsed) const shown = shownCount(props) const empty = rows.length === 0 - const text = copyText(props) - - const onCopy = useCallback(() => { - if (copied) return - void writeClipboard(text).then((ok) => { - if (!ok) return - setCopied(true) - window.setTimeout(() => { setCopied(false) }, 1000) - }) - }, [copied, text]) + const { copied, onCopy } = useCopyFeedback(copyText(props)) const onToggle = useCallback(() => { setExpanded(value => !value) }, []) @@ -202,12 +193,7 @@ export function SearchBlock(props: SearchBlockProps) { }) }, []) - const hidden = rows.length - maxLines - const capped = hidden > 0 && !expanded - // Same split arithmetic as TerminalBlock (and the TUI transcript's collapsed - // tool card), so a long result's head and tail slices agree across surfaces. - const headLines = Math.ceil(maxLines / 2) - const tailLines = maxLines - headLines + const { hidden, capped, headLines, tailLines } = headTailCap(rows.length, maxLines, expanded) const head = capped ? rows.slice(0, headLines) : rows const naturalTail = capped ? rows.slice(rows.length - tailLines) : [] // When the tail slice begins inside a file's matches, its own header sits diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index c707711f69..63fb554473 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -8,7 +8,8 @@ import { useCallback, useMemo, useState } from 'react' import clsx from 'clsx' import { parseAnsiLines, type AnsiLine } from './ansi.ts' -import { writeClipboard } from './clipboard.ts' +import { headTailCap } from './head-tail-cap.ts' +import { useCopyFeedback } from './use-copy-feedback.ts' import { Pill } from './Pill.tsx' import { StateDot, type StateDotState } from './StateDot.tsx' import css from './TerminalBlock.module.css' @@ -140,18 +141,9 @@ export function TerminalBlock({ return terminated ? parsed.slice(0, -1) : parsed }, [text]) const [expanded, setExpanded] = useState(false) - const [copied, setCopied] = useState(false) - - const onCopy = useCallback(() => { - if (copied) return - // The raw output, never the rendered tree: the prompt line and the status - // pill are chrome the user did not run. - void writeClipboard(text).then((ok) => { - if (!ok) return - setCopied(true) - window.setTimeout(() => { setCopied(false) }, 1000) - }) - }, [copied, text]) + // The raw output, never the rendered tree: the prompt line and the status pill + // are chrome the user did not run. + const { copied, onCopy } = useCopyFeedback(text) const onToggle = useCallback(() => { setExpanded(value => !value) }, []) @@ -170,12 +162,7 @@ export function TerminalBlock({ // the raw text drew an output box of blank rows plus a copy control for // invisible bytes, and hid the placeholder that belongs there. const empty = lines.every(line => line.every(span => span.text.trim() === '')) - const hidden = lines.length - maxLines - const capped = hidden > 0 && !expanded - // Same split arithmetic as the TUI transcript's collapsed tool card, so a - // command's head and tail slices agree between the two front ends. - const headLines = Math.ceil(maxLines / 2) - const tailLines = maxLines - headLines + const { hidden, capped, headLines, tailLines } = headTailCap(lines.length, maxLines, expanded) return (
diff --git a/packages/client/ui-primitives/src/head-tail-cap.ts b/packages/client/ui-primitives/src/head-tail-cap.ts new file mode 100644 index 0000000000..1ac540dd21 --- /dev/null +++ b/packages/client/ui-primitives/src/head-tail-cap.ts @@ -0,0 +1,33 @@ +// Head/tail height-cap arithmetic shared by the block primitives (TerminalBlock, +// SearchBlock) and matching the TUI transcript's collapsed tool card, so a long +// result's head and tail slices agree across every surface. The split is +// `ceil(maxLines / 2)` head rows and the remainder as tail rows; a result within +// the cap shows every row and hides none. + +/** The head/tail split metrics for a capped list. */ +export interface HeadTailCap { + /** Rows beyond the cap (list length − maxLines); ≤ 0 means nothing is hidden. */ + hidden: number + /** Whether the list is over the cap and not expanded, so it shows a head/tail slice. */ + capped: boolean + /** Head-slice row count: `ceil(maxLines / 2)`. */ + headLines: number + /** Tail-slice row count: the remainder after the head. */ + tailLines: number +} + +/** + * Compute the head/tail cap metrics for a list of `total` rows against `maxLines`, + * given whether the surface is expanded. Pure arithmetic; the caller slices its + * own rows with `headLines`/`tailLines` so a block can layer its own concerns + * (SearchBlock restores a tail file header) on top. + * @param total - the list's row count. + * @param maxLines - the collapsed-height cap in rows. + * @param expanded - whether the surface is expanded (uncaps the list). + * @returns the split metrics. + */ +export function headTailCap(total: number, maxLines: number, expanded: boolean): HeadTailCap { + const hidden = total - maxLines + const headLines = Math.ceil(maxLines / 2) + return { hidden, capped: hidden > 0 && !expanded, headLines, tailLines: maxLines - headLines } +} diff --git a/packages/client/ui-primitives/src/use-copy-feedback.ts b/packages/client/ui-primitives/src/use-copy-feedback.ts new file mode 100644 index 0000000000..1340a00625 --- /dev/null +++ b/packages/client/ui-primitives/src/use-copy-feedback.ts @@ -0,0 +1,37 @@ +// The copy-to-clipboard-with-feedback hook shared by the block primitives +// (TerminalBlock, SearchBlock): write the given text, and on success flip a +// transient `copied` flag that the caller renders as a "复制成功" label for one +// second. A refused write leaves the flag untouched, so the control never claims +// a copy the host declined. + +import { useCallback, useState } from 'react' +import { writeClipboard } from './clipboard.ts' + +/** How long the `copied` flag stays true after a successful write, in ms. */ +const COPIED_FEEDBACK_MS = 1000 + +/** The copy-feedback hook's return: the transient flag and the copy handler. */ +export interface CopyFeedback { + /** True for {@link COPIED_FEEDBACK_MS} after a successful write; render the success label off it. */ + copied: boolean + /** Copy the hook's text; no-op while `copied` is still true, silent on a refused write. */ + onCopy: () => void +} + +/** + * Copy `text` to the clipboard with one-second success feedback. + * @param text - the text to write on copy. + * @returns the `copied` flag and the `onCopy` handler. + */ +export function useCopyFeedback(text: string): CopyFeedback { + const [copied, setCopied] = useState(false) + const onCopy = useCallback(() => { + if (copied) return + void writeClipboard(text).then((ok) => { + if (!ok) return + setCopied(true) + window.setTimeout(() => { setCopied(false) }, COPIED_FEEDBACK_MS) + }) + }, [copied, text]) + return { copied, onCopy } +} From be44f073e1f4363e0f92116f08a2f466df289054 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 00:20:18 +0800 Subject: [PATCH 40/47] Fix invariant readiness after CI sync --- .github/workflows/ci.yml | 9 +- .../client/connection/tests/node-half.spec.ts | 20 +- .../runtime/tests/slots-service.spec.ts | 4 +- scripts/test-invariants.spec.ts | 260 +++++++++++++++++- scripts/test-invariants.ts | 86 +++++- vitest.config.ts | 23 +- 6 files changed, 352 insertions(+), 50 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f6ec52f0a..e607d83bdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,11 +105,10 @@ jobs: || 'dsh-ubuntu-24-04-16core' }} name: node 24 / coverage env: - # Failover shrinks the worker bound: the hosted 32-core runner is - # exclusive to one job, but the failover pool shares one 64-core VM - # across six always-on runner instances, and the timing-sensitive - # process suites have documented aggregate-contention failures. - # 8 × 6 instances = 48 workers worst case on 64 cores. + # The hosted 16-core runner uses six coverage workers. The failover pool + # shares one 64-core VM across six always-on runner instances, so each + # instance may use eight while keeping the worst case at 8 × 6 = 48 + # workers; process-bound suites remain isolated in forks. DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '6' }} DSH_GATE_CONCURRENCY: '3' steps: diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 2c7fd0b281..9efa9cbd51 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -59,23 +59,9 @@ describe('connection node half', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) ctx.provide('apiProxy', {} as unknown as ApiProxy) - // The apply throw also escapes cordis as a late rejection — the shape the - // boot's installFailLoud is contracted to catch. Capture it so the run - // stays clean, same pattern as the webserver bind-failure test. - const rejections: unknown[] = [] - const onUnhandled = (err: unknown): void => { rejections.push(err) } - process.on('unhandledRejection', onUnhandled) - try { - const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) - await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/) - expect(routes).toHaveLength(0) - for (let i = 0; i < 100 && rejections.length === 0; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) - } - expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority') - } finally { - process.off('unhandledRejection', onUnhandled) - } + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) + await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) + expect(routes).toHaveLength(0) }) it('registers the /api prefix route and removes it with the fiber', async () => { diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 07e03b6e9d..b2e510a26f 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -41,8 +41,8 @@ interface Bench { async function boot(): Promise { const ctx = new Context() - ctx.plugin(SlotsService) - await ctx.fiber.await() + const fiber = ctx.plugin(SlotsService) + await fiber // Service accessor (ctx.get reads the reflect store, which Service-class // plugins do not write; the accessor is the product path). const svc = ctx.slots diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 0fb6aeb201..18c3a7f860 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -1,11 +1,14 @@ import { describe, expect, it, vi } from 'vitest' -import { Context, Service } from 'cordis' +import { Context, FiberState, Service } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import InvariantService from '@deepseek-ai/dsh-invariants' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' import { + TEST_INVARIANT_READY_SERVICE, testInvariantCompanionPaths, testInvariantCompanions, + type TestInvariantCompanion, usesManualInvariantTree, } from './test-invariants.ts' @@ -21,6 +24,32 @@ class TestInvariantProbe extends Service { } } +function deferred(): { readonly promise: Promise; readonly resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +async function withFakeCompanions( + create: (path: string, index: number) => () => Promise, + run: () => Promise, +): Promise { + const mutable = testInvariantCompanions as Record Promise> + const originals = Object.entries(mutable) + for (const [index, [path]] of originals.entries()) { + mutable[path] = create(path, index) + } + try { + await run() + } finally { + for (const [path, load] of originals) { + mutable[path] = load + } + } +} + describe('global test invariant host', () => { it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { const ctx = new Context() @@ -85,4 +114,233 @@ describe('global test invariant host', () => { expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true) expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false) }) + + it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => { + const delayedStarted = deferred() + const releaseDelayed = deferred() + const order: string[] = [] + let delayedCompanion: TestInvariantCompanion | undefined + const companionNestedApply = vi.fn(function companionNestedApply() {}) + + await withFakeCompanions( + (path, index) => async () => { + const companion: TestInvariantCompanion = { + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply(companionCtx) { + order.push(`companion-start:${path}`) + if (index === 0) { + delayedStarted.resolve() + await releaseDelayed.promise + } + if (index === 1) await companionCtx.plugin(companionNestedApply) + order.push(`companion-active:${path}`) + return () => {} + }, + } + if (index === 0) delayedCompanion = companion + return companion + }, + async () => { + const ctx = new Context() + ctx.provide('testInvariantTargetDependency', true) + let nestedFiber: ReturnType | undefined + const nestedApply = vi.fn(function nestedApply() { + order.push('nested') + }) + const targetApply = Object.assign(vi.fn(function targetApply(targetCtx: Context) { + order.push('target') + nestedFiber = targetCtx.plugin(nestedApply) + }), { + inject: ['testInvariantTargetDependency'], + }) + + const targetFiber = ctx.plugin(targetApply) + expect(ctx.registry.get(targetApply)?.callback).toBe(targetApply) + expect(targetFiber.inject).toEqual({ + testInvariantTargetDependency: null, + [TEST_INVARIANT_READY_SERVICE]: null, + }) + + await delayedStarted.promise + await Promise.resolve() + await Promise.resolve() + expect(targetApply).not.toHaveBeenCalled() + + releaseDelayed.resolve() + await targetFiber + if (nestedFiber === undefined) throw new Error('target did not register its nested plugin') + await nestedFiber + + expect(targetFiber.state).toBe(FiberState.ACTIVE) + expect(targetApply).toHaveBeenCalledOnce() + expect(nestedApply).toHaveBeenCalledOnce() + expect(companionNestedApply).toHaveBeenCalledOnce() + const targetIndex = order.indexOf('target') + expect(targetIndex).toBeGreaterThan(-1) + expect(order.slice(0, targetIndex)).toHaveLength(Object.keys(testInvariantCompanions).length * 2) + expect(order.at(-1)).toBe('nested') + + if (delayedCompanion === undefined) throw new Error('delayed companion did not load') + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(delayedCompanion) + expect(ctx.registry.get(InvariantService)?.fibers).toHaveLength(1) + expect(ctx.registry.get(delayedCompanion)?.fibers).toHaveLength(1) + }, + ) + }) + + it('holds plugins registered on a root-derived context until companion readiness', async () => { + const delayedStarted = deferred() + const releaseDelayed = deferred() + + await withFakeCompanions( + (_path, index) => async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (index === 0) { + delayedStarted.resolve() + await releaseDelayed.promise + } + return () => {} + }, + }), + async () => { + const ctx = new Context() + const rootApply = vi.fn(function rootApply() {}) + const derivedApply = vi.fn(function derivedApply() {}) + const derived = ctx.extend() + .isolate('testInvariantDerived') + .intercept('testInvariantDerived', {}) + + const rootFiber = ctx.plugin(rootApply) + const derivedFiber = derived.plugin(derivedApply) + + await delayedStarted.promise + await Promise.resolve() + await Promise.resolve() + expect(rootApply).not.toHaveBeenCalled() + expect(derivedApply).not.toHaveBeenCalled() + expect(derivedFiber.inject).toEqual({ + [TEST_INVARIANT_READY_SERVICE]: null, + }) + + releaseDelayed.resolve() + await Promise.all([rootFiber, derivedFiber]) + expect(rootFiber.state).toBe(FiberState.ACTIVE) + expect(derivedFiber.state).toBe(FiberState.ACTIVE) + expect(rootApply).toHaveBeenCalledOnce() + expect(derivedApply).toHaveBeenCalledOnce() + }, + ) + }) + + it('holds a child registered externally on a pending target context', async () => { + const delayedStarted = deferred() + const releaseDelayed = deferred() + + await withFakeCompanions( + (_path, index) => async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (index === 0) { + delayedStarted.resolve() + await releaseDelayed.promise + } + return () => {} + }, + }), + async () => { + const ctx = new Context() + const targetApply = vi.fn(function targetApply() {}) + const childApply = vi.fn(function childApply() {}) + + const targetFiber = ctx.plugin(targetApply) + const childFiber = targetFiber.ctx.plugin(childApply) + + await delayedStarted.promise + await Promise.resolve() + await Promise.resolve() + expect(targetFiber.state).toBe(FiberState.PENDING) + expect(childFiber.state).toBe(FiberState.PENDING) + expect(targetApply).not.toHaveBeenCalled() + expect(childApply).not.toHaveBeenCalled() + expect(childFiber.inject).toEqual({ + [TEST_INVARIANT_READY_SERVICE]: null, + }) + + releaseDelayed.resolve() + await Promise.all([targetFiber, childFiber]) + expect(targetFiber.state).toBe(FiberState.ACTIVE) + expect(childFiber.state).toBe(FiberState.ACTIVE) + expect(targetApply).toHaveBeenCalledOnce() + expect(childApply).toHaveBeenCalledOnce() + }, + ) + }) + + it.each(['load', 'startup'] as const)( + 'rejects a target when a lazy companion fails during %s without starting the target', + async (phase) => { + const failure = new Error(`test invariant companion ${phase} failed`) + await withFakeCompanions( + (_path, index) => phase === 'load' && index === 0 + ? async () => { throw failure } + : async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (phase === 'startup' && index === 0) throw failure + return () => {} + }, + }), + async () => { + const ctx = new Context() + const targetApply = vi.fn(function targetApply() {}) + const targetFiber = ctx.plugin(targetApply) + + await expect(targetFiber).rejects.toBe(failure) + expect(targetApply).not.toHaveBeenCalled() + expect(targetFiber.state).toBe(FiberState.PENDING) + await expect(targetFiber.dispose()).resolves.toBeUndefined() + expect(targetFiber.state).toBe(FiberState.DISPOSED) + }, + ) + }, + ) + + it('disposes a pending target without waiting for companion readiness', async () => { + const delayedStarted = deferred() + const releaseDelayed = deferred() + + await withFakeCompanions( + (_path, index) => async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (index === 0) { + delayedStarted.resolve() + await releaseDelayed.promise + } + return () => {} + }, + }), + async () => { + const ctx = new Context() + const targetApply = vi.fn(function targetApply() {}) + const targetFiber = ctx.plugin(targetApply) + + await delayedStarted.promise + await expect(targetFiber.dispose()).resolves.toBeUndefined() + expect(targetFiber.state).toBe(FiberState.DISPOSED) + expect(targetApply).not.toHaveBeenCalled() + + releaseDelayed.resolve() + await targetFiber + expect(targetApply).not.toHaveBeenCalled() + }, + ) + }) }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 62c0102588..8ebf7a6243 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -6,7 +6,7 @@ */ import { expect } from 'vitest' -import { RegistryService } from 'cordis' +import { FiberState, Inject, RegistryService } from 'cordis' import type { Context, Plugin } from 'cordis' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -25,6 +25,9 @@ export interface TestInvariantCompanion { apply(ctx: Context): Promise<() => void> } +/** Private service dependency that holds ordinary root plugins until invariant startup completes. */ +export const TEST_INVARIANT_READY_SERVICE = 'testInvariantReady' + /** * Every package companion as a lazy loader keyed by glob path. Ordinary tests * load only their owner's module; the exhaustive topology test loads and @@ -43,10 +46,12 @@ const MANUAL_INVARIANT_TEST_EXCEPTIONS = [ interface InvariantHost { readonly byCallback: ReadonlyMap + readonly barrierOwners: WeakSet readonly ready: Promise } type PluginFiber = ReturnType +type PluginCallback = Plugin.Function | Plugin.Constructor const hosts = new WeakMap() // oxlint-disable-next-line typescript/unbound-method -- every call below supplies its RegistryService receiver explicitly. @@ -61,13 +66,24 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge const callback = this.resolve(plugin) const existing = callback === undefined ? undefined : host.byCallback.get(callback) if (existing !== undefined) { - return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing + return hasBarrierOwner(host, this.ctx) ? existing : joinInvariantStartup(existing, host.ready) } - const fiber = originalPlugin.call(this, plugin, config, getOuterStack) - // A root-level await is the test's composition boundary. Nested plugin - // fibers must not await their own companion parent through the global host. - if (this.ctx !== root) return fiber + // Causal descendants of a gated target have already crossed the barrier. + // Host service and companion descendants also bypass it so their own startup + // cannot depend on the readiness they are responsible for providing. + if (hasBarrierOwner(host, this.ctx)) { + return originalPlugin.call(this, plugin, config, getOuterStack) + } + if (callback === undefined) return originalPlugin.call(this, plugin, config, getOuterStack) + + const fiber = originalPlugin.call( + this, + withInvariantReadiness(plugin, callback as PluginCallback), + config, + getOuterStack, + ) + host.barrierOwners.add(fiber.ctx.fiber) return joinInvariantStartup(fiber, host.ready) } @@ -108,11 +124,13 @@ export function testInvariantCompanionPaths(testPath: string): string[] { function startInvariantHost(root: Context): InvariantHost { const byCallback = new Map() + const barrierOwners = new WeakSet() const mount = (plugin: Plugin, config?: unknown): PluginFiber => { const fiber = originalPlugin.call(root.registry, plugin, config) const callback = root.registry.resolve(plugin) if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin') byCallback.set(callback, fiber) + barrierOwners.add(fiber.ctx.fiber) return fiber } @@ -126,8 +144,8 @@ function startInvariantHost(root: Context): InvariantHost { const serviceFiber = mount(InvariantService, { enabled: true }) const testPath = expect.getState().testPath ?? '' const companionPaths = testInvariantCompanionPaths(testPath) - const ready = serviceFiber.await().then(async () => { - const companionFibers = await Promise.all(companionPaths.map(async (path) => { + const ready = requireActive(serviceFiber, 'invariant service').then(async () => { + const companions = await Promise.all(companionPaths.map(async (path) => { const load = testInvariantCompanions[path] if (load === undefined) { throw new Error(`test invariants: selected companion vanished at ${path}`) @@ -136,20 +154,58 @@ function startInvariantHost(root: Context): InvariantHost { if (!companion.inject.includes('invariants')) { throw new Error(`test invariants: ${path} must inject the invariant service`) } - return mount(companion) + return { companion, path } })) - await Promise.all(companionFibers.map(fiber => fiber.await())) + const companionFibers = companions.map(({ companion, path }) => ({ + fiber: mount(companion), + path, + })) + await Promise.all(companionFibers.map(({ fiber, path }) => requireActive(fiber, path))) + root.provide(TEST_INVARIANT_READY_SERVICE, true) }) - const host = { byCallback, ready } + const host = { byCallback, barrierOwners, ready } hosts.set(root, host) return host } +function hasBarrierOwner(host: InvariantHost, ctx: Context): boolean { + let fiber = ctx.fiber + while (true) { + if ( + host.barrierOwners.has(fiber) + && (fiber.state === FiberState.LOADING || fiber.state === FiberState.ACTIVE) + ) { + return true + } + const parent = fiber.parent.fiber + if (parent === fiber) return false + fiber = parent + } +} + +async function requireActive(fiber: PluginFiber, label: string): Promise { + await fiber.await() + if (fiber.state !== FiberState.ACTIVE) { + throw new Error(`test invariants: ${label} settled without becoming active`) + } +} + +function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugin.Object { + return { + apply: callback as Plugin.Function, + inject: { + ...Inject.resolve(plugin.inject), + [TEST_INVARIANT_READY_SERVICE]: null, + }, + ...(plugin.name === undefined ? {} : { name: plugin.name }), + ...(plugin.Config === undefined ? {} : { Config: plugin.Config }), + ...(plugin.provide === undefined ? {} : { provide: plugin.provide }), + ...(plugin.intercept === undefined ? {} : { intercept: plugin.intercept }), + } +} + function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { - const readiness = fiber.await().then(async (loaded) => { - await invariantReady - return loaded - }) + const readiness = invariantReady.then(() => fiber.await()) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined diff --git a/vitest.config.ts b/vitest.config.ts index aa3e2b441b..0f4aac1e31 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -37,9 +37,9 @@ const testIncludes = [ 'scripts/**/*.spec.ts', ] -// These suites exercise process-global state, process APIs, or timing-sensitive process I/O -// that worker threads cannot isolate reliably under aggregate gate contention. -// Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. +// These suites exercise process-global state, process APIs, or timing-sensitive process I/O. +// Keep them in a separate project so Windows, whose main pool uses threads, +// still contains them in forks; POSIX uses forks for both projects. const processBoundTests = [ 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', @@ -55,8 +55,9 @@ export default defineConfig({ // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), - // One coverage invocation aggregates both projects. Most suites use threads - // for lower startup/IPC overhead; only explicit process-bound suites fork. + // One coverage invocation aggregates both projects. POSIX uses forks to + // contain the Node CJS-lexer abort; Windows keeps threads for the main + // inventory and forks only the explicit process-bound project. projects: [ { plugins: [pathsPlugin()], @@ -156,11 +157,13 @@ export default defineConfig({ 'packages/client/ui-sidebar/src/client/index.ts', 'packages/client/ui-skill/src/client/index.ts', 'packages/client/ui-workspace/src/client/index.ts', - // Typert generator: correctness is pinned by its fixture suites and - // the byte-for-byte catalog reproduction test; per-file coverage - // would put whole-workspace compiler analysis under v8 - // instrumentation — the coverage lane's longest tail. - 'packages/typert/generator/src/*.ts', + // These three whole-workspace Typert passes are pinned by fixture and + // byte-for-byte catalog tests; v8 instrumentation makes them the + // coverage lane's longest tail. The generator's lighter modules and + // future source files retain the 100% per-file threshold. + 'packages/typert/generator/src/analyzer.ts', + 'packages/typert/generator/src/renderer.ts', + 'packages/typert/generator/src/cordis-catalog.ts', 'packages/host/apiproxy/src/index.ts', 'packages/host/apiproxy/src/invariant.ts', 'packages/host/apiproxy/src/api-proxy.ts', From 0378a72431dcde1526630f0fac4a60adf0c845a9 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 00:33:03 +0800 Subject: [PATCH 41/47] Align trajectory bundle test with session history --- .../client/ui-trajectory/tests/client-bundle.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index ccd811d289..cb0e510ec5 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -60,7 +60,7 @@ describe('tsdown client artifact', () => { const { handoff, surface } = await loadArtifact() expect(handoff.id).toBe(PLUGIN_ID) expect(surface.apply).toBeTypeOf('function') - expect(surface.inject).toEqual(['slots', 'conversation', 'sessions']) + expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory']) }) it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { @@ -72,10 +72,11 @@ describe('tsdown client artifact', () => { name: 'root', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) - // The plugin injects 'conversation' as an ordering edge and 'sessions' - // for its per-session history callback; this bench supplies both. + // The plugin injects 'conversation' as an ordering edge and + // 'sessionHistory' for its per-session history callback; this bench + // supplies both. ctx.provide('conversation', {}) - ctx.provide('sessions', {}) + ctx.provide('sessionHistory', {}) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) From 00cc600b148dcb4a1862792a4c2a43e2ff9ecc12 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 09:45:48 +0800 Subject: [PATCH 42/47] fix(snapshot): canonicalize macOS cwd tokens --- .../tests/snapshots/fs-edit/session.jsonl | 2 +- .../fs-escalation-approved/session.jsonl | 2 +- .../snapshots/fs-policy-reject/session.jsonl | 4 ++-- .../snapshots/fs-write-overwrite/session.jsonl | 2 +- .../tests/snapshots/fs-write/session.jsonl | 2 +- packages/support/acp-snapshot/README.i18n.yaml | 4 ++-- packages/support/acp-snapshot/README.md | 4 ++-- packages/support/acp-snapshot/README.zh.md | 4 ++-- packages/support/acp-snapshot/src/normalize.ts | 2 +- packages/support/acp-snapshot/src/suite.ts | 7 +++++-- .../acp-snapshot/tests/normalize.spec.ts | 18 ++++++++++++++++++ 11 files changed, 36 insertions(+), 15 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl index 4438b7991e..0525c835f7 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl @@ -28,7 +28,7 @@ {"type":"assistant/chunk","seq":129,"time":1785460622793,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1785460622793,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."},{"type":"tool-call","id":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"75f1ca92-c259-495a-adff-c120b4d16f9e"},"usage":{"inputTokens":241,"outputTokens":98,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1785460622794,"data":{"turn":1,"step":2,"callId":"call_00_vOytneZ0XpsLslEEJAxR6398","name":"edit","arguments":"{\"file_path\": \"config.txt\", \"old_string\": \"DEBUG\", \"new_string\": \"RELEASE\"}"}} -{"type":"tool/result","seq":132,"time":1785460622810,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_vOytneZ0XpsLslEEJAxR6398"},"content":[{"type":"tool-result","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file /private{{cwd}}/config.txt has been updated successfully."}],"isError":false}],"role":"user","id":"de4b3301-beb5-4617-8dcc-31d5e093da01"},"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[131],"surfaceOp":"append"} +{"type":"tool/result","seq":132,"time":1785460622810,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_vOytneZ0XpsLslEEJAxR6398"},"content":[{"type":"tool-result","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","content":[{"type":"text","text":"The file {{cwd}}/config.txt has been updated successfully."}],"isError":false}],"role":"user","id":"de4b3301-beb5-4617-8dcc-31d5e093da01"},"meta":{"diffs":[{"path":"config.txt","oldText":"mode=DEBUG\nlevel=info","newText":"mode=RELEASE\nlevel=info"}]}},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":133,"time":1785460622810,"data":{"turn":1,"step":2}} {"type":"step/start","seq":134,"time":1785460622819,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":135,"time":1783352088286,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl index af97d3863c..b835bdd6c4 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl @@ -17,7 +17,7 @@ {"type":"tool/call","seq":88,"time":1785460673960,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}} {"type":"approval/asked","seq":89,"time":1785460673969,"data":{"id":"45093643-7bc5-4566-8e23-8b3d85bc99bb","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}} {"type":"approval/decided","seq":90,"time":1785460673970,"data":{"id":"45093643-7bc5-4566-8e23-8b3d85bc99bb","outcome":"allowed-once"}} -{"type":"tool/result","seq":91,"time":1785460673984,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"/private{{cwd}}/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"5fbf39be-f28f-4139-a679-bf00b4faadb0"},"meta":{"diffs":[]}},"sourceEventSeqs":[88],"surfaceOp":"append"} +{"type":"tool/result","seq":91,"time":1785460673984,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"content":[{"type":"tool-result","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"{{cwd}}/escalated.md\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"5fbf39be-f28f-4139-a679-bf00b4faadb0"},"meta":{"diffs":[]}},"sourceEventSeqs":[88],"surfaceOp":"append"} {"type":"step/end","seq":92,"time":1785460673984,"data":{"turn":1,"step":1}} {"type":"step/start","seq":93,"time":1785460673992,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":94,"time":1784821264916,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl index f3a24c527d..e7da8d7624 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":77,"time":1785460626705,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":78,"time":1785460626705,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"940a6776-3320-48d4-8a9d-d1781d6011e1"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77],"surfaceOp":"append"} {"type":"tool/call","seq":79,"time":1785460626705,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":80,"time":1785460626713,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"/private{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"7e6b24ff-2253-4528-980c-25bc2ddc31f3"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[79],"surfaceOp":"append"} +{"type":"tool/result","seq":80,"time":1785460626713,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"7e6b24ff-2253-4528-980c-25bc2ddc31f3"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[79],"surfaceOp":"append"} {"type":"step/end","seq":81,"time":1785460626713,"data":{"turn":1,"step":1}} {"type":"step/start","seq":82,"time":1785460626721,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":83,"time":1783611704825,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -41,7 +41,7 @@ {"type":"assistant/chunk","seq":225,"time":1785460626753,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":226,"time":1785460626753,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f85bbc8f-9b07-480e-8e4b-086420c53b09"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225],"surfaceOp":"append"} {"type":"tool/call","seq":227,"time":1785460626753,"data":{"turn":1,"step":3,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","seq":228,"time":1785460626768,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file /private{{cwd}}/settings.txt has been updated successfully."}],"isError":false}],"role":"user","id":"b472a78b-210d-46b7-a2df-0216f59ad15a"},"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[227],"surfaceOp":"append"} +{"type":"tool/result","seq":228,"time":1785460626768,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"The file {{cwd}}/settings.txt has been updated successfully."}],"isError":false}],"role":"user","id":"b472a78b-210d-46b7-a2df-0216f59ad15a"},"meta":{"diffs":[{"path":"settings.txt","oldText":"color: blue","newText":"color: green"}]}},"sourceEventSeqs":[227],"surfaceOp":"append"} {"type":"step/end","seq":229,"time":1785460626768,"data":{"turn":1,"step":3}} {"type":"step/start","seq":230,"time":1785460626775,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":231,"time":1783611707747,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl index da80e68eef..c2c5fb797d 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl @@ -28,7 +28,7 @@ {"type":"assistant/chunk","seq":114,"time":1785460624121,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":115,"time":1785460624121,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."},{"type":"tool-call","id":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"582fbd7b-0d94-4672-a03b-1309d08e59b8"},"usage":{"inputTokens":228,"outputTokens":79,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114],"surfaceOp":"append"} {"type":"tool/call","seq":116,"time":1785460624121,"data":{"turn":1,"step":2,"callId":"call_00_N23EvXjDo4c8enyWpIUq4043","name":"write","arguments":"{\"file_path\": \"data.txt\", \"content\": \"replaced\"}"}} -{"type":"tool/result","seq":117,"time":1785460624136,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_N23EvXjDo4c8enyWpIUq4043"},"content":[{"type":"tool-result","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"/private{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"51131116-d86a-4154-906e-b98db5cc2cda"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[116],"surfaceOp":"append"} +{"type":"tool/result","seq":117,"time":1785460624136,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_N23EvXjDo4c8enyWpIUq4043"},"content":[{"type":"tool-result","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}],"isError":false}],"role":"user","id":"51131116-d86a-4154-906e-b98db5cc2cda"},"meta":{"diffs":[{"path":"data.txt","oldText":"original contents","newText":"replaced"}]}},"sourceEventSeqs":[116],"surfaceOp":"append"} {"type":"step/end","seq":118,"time":1785460624136,"data":{"turn":1,"step":2}} {"type":"step/start","seq":119,"time":1785460624143,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":120,"time":1783352096090,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl index b9cf385870..6d07fbffdd 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/session.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":62,"time":1785460621455,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":63,"time":1785460621455,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"60eec6c2-9c29-4347-97e6-3f6cb97ed023"},"usage":{"inputTokens":2891,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":30}},"sourceEventSeqs":[6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62],"surfaceOp":"append"} {"type":"tool/call","seq":64,"time":1785460621455,"data":{"turn":1,"step":1,"callId":"call_00_APMUCJJm9lrTSlVbg6dB0185","name":"write","arguments":"{\"file_path\": \"notes.txt\", \"content\": \"hello world\"}"}} -{"type":"tool/result","seq":65,"time":1785460621470,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"/private{{cwd}}/notes.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"965d2bc5-16b5-41d9-ab5b-ada4184f5604"},"meta":{"diffs":[]}},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"tool/result","seq":65,"time":1785460621470,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_APMUCJJm9lrTSlVbg6dB0185"},"content":[{"type":"tool-result","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","content":[{"type":"text","text":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}],"isError":false}],"role":"user","id":"965d2bc5-16b5-41d9-ab5b-ada4184f5604"},"meta":{"diffs":[]}},"sourceEventSeqs":[64],"surfaceOp":"append"} {"type":"step/end","seq":66,"time":1785460621470,"data":{"turn":1,"step":1}} {"type":"step/start","seq":67,"time":1785460621478,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":68,"time":1783352080826,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 363e0f268c..376cea9dcd 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.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 packages/support/acp-snapshot/README.md -README.md: 948c33a91977f078d16842c285011bf8f83623bd -README.zh.md: fb86bd4e236be1c79f66dc46fbaac4d7dfbf9977 +README.md: e7988733827ef1d4de67d6d49764a4836e33d17c +README.zh.md: e2466feb5e2025cb99f252b4206bfacb711bccda diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 948c33a919..e798873382 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -8,8 +8,8 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. -- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → `{{cwd}}`, authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **Normalizers** — pure functions turning captured surfaces into stable text or portable fixtures: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → one canonical `{{cwd}}`, including an already-tokenized macOS `/private` alias; authored temp paths unchanged), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, noncanonical macOS-prefixed cwd tokens, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index fb86bd4e23..e2466feb5e 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -8,8 +8,8 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 -- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名 → `{{cwd}}`,手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 -- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 +- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名,包括已 token 化的 macOS `/private` 别名 → 单一规范 `{{cwd}}`;手工编写的临时路径保持不变)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、带非规范 macOS 前缀的 cwd token、未擦除的 JSONL header,以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index e010afebcb..a3d5e66aa1 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -197,7 +197,7 @@ function tokenizeFixtureString(value: string, ctx: NormalizeContext, basename: s + String.raw`(?=$|[\\/\s<>'"()\[\]{},;:!?=])`, 'g', ) - return exact.replace(absoluteCwd, CWD) + return exact.replace(absoluteCwd, CWD).split(`/private${CWD}`).join(CWD) } /** Recursively replace generated-cwd spellings while preserving every other JSON value. */ diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 1a8a49dac5..a641bd19cd 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -1269,9 +1269,10 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { assertUniqueSnapshotContents('tool-schema', schemas) }) - it('every committed JSONL has valid tool results and canonical header storage', async () => { + it('every committed JSONL has valid tool results and canonical fixture storage', async () => { // Prompts and schemas always leave JSONL. Header pins retain prefixes; - // every other fixture tokenizes those too. Fixed-point checks make both + // every other fixture tokenizes those too. Portable cwd tokens never + // retain a platform realpath prefix. Fixed-point checks make these // storage rules fail loud. for (const scenario of scenarios) { const dir = join(snapshotsDir, scenario.name) @@ -1280,6 +1281,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const fixture = await readFile(join(dir, file), 'utf8') expect(unknownToolCallIds(fixture), `${scenario.name}/${file} contains UNKNOWN_TOOL`) .toEqual([]) + expect(fixture, `${scenario.name}/${file} carries a non-canonical macOS cwd token`) + .not.toContain('/private{{cwd}}') expect(scrubSystemPrompts(fixture), `${scenario.name}/${file} carries an unscrubbed system prompt`) .toEqual(fixture) expect(scrubToolSchemas(fixture), `${scenario.name}/${file} carries unscrubbed tool schemas`) diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index 8bc6a66d14..aea21eb747 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -470,6 +470,24 @@ describe('tokenizeSessionFixtureCwd', () => { expect(tokenizeSessionFixtureCwd(out)).toBe(out) }) + it('collapses a residual macOS realpath prefix around an existing cwd token', () => { + const raw = [ + JSON.stringify({ type: 'session', id: 's', createdAt: 1, cwd: '{{cwd}}' }), + JSON.stringify({ + type: 'tool/result', + seq: 1, + time: 2, + data: { content: [{ type: 'text', text: 'wrote /private{{cwd}}/proof.txt' }] }, + }), + '', + ].join('\n') + + const out = tokenizeSessionFixtureCwd(raw) + expect(out).toContain('wrote {{cwd}}/proof.txt') + expect(out).not.toContain('/private{{cwd}}') + expect(tokenizeSessionFixtureCwd(out)).toBe(out) + }) + it('rejects a log without a session cwd', () => { expect(() => tokenizeSessionFixtureCwd('')).toThrow( 'acp-snapshot: cannot tokenize a cwd without a basename', From 669acedcadeb5890d72b7b4dbc67d07dba093dc5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 10:41:44 +0800 Subject: [PATCH 43/47] fix(test): preserve invariant config failures --- scripts/test-invariants.spec.ts | 205 ++++++++++++++++++++++++++------ scripts/test-invariants.ts | 28 ++++- 2 files changed, 190 insertions(+), 43 deletions(-) diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 08900b4e1e..2c00bc7439 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { Context, FiberState, Service } from 'cordis' +import { Context, FiberState, Service, ValidationError } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import z from 'schemastery' import InvariantService from '@deepseek-ai/dsh-invariants' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' @@ -32,23 +33,40 @@ function deferred(): { readonly promise: Promise; readonly resolve: () => return { promise, resolve } } -function delayedCompanions( - delayedStarted: ReturnType, - releaseDelayed: ReturnType, -): (_path: string, index: number) => () => Promise { - return (_path, index) => async () => ({ - name: `test-invariant-${index}`, - inject: ['invariants'], - async apply() { - if (index === 0) { - delayedStarted.resolve() - await releaseDelayed.promise - } - return () => {} - }, +function requiredConfig() { + return z.object({ + requiredValue: z.string().required(), }) } +function queuedReadinessConfig( + ctx: Context, + onPublished: (dispose: () => void) => void, +) { + return z.transform(z.any(), () => { + queueMicrotask(() => { + onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true)) + }) + return {} + }, true) +} + +function invalidConfigApply(): never { + throw new Error('invalid plugin apply executed') +} + +async function rejectionOf(fiber: ReturnType): Promise { + return fiber.then( + () => undefined, + (error: unknown) => error, + ) +} + +function expectRequiredConfigValidation(error: unknown): void { + expect(error).toBeInstanceOf(ValidationError) + expect(error).toHaveProperty('message', expect.stringMatching(/requiredValue/)) +} + async function withFakeCompanions( create: (path: string, index: number) => () => Promise, run: () => Promise, @@ -67,6 +85,27 @@ async function withFakeCompanions( } } +async function withDelayedFirstCompanion( + run: (control: { readonly started: Promise; readonly release: () => void }) => Promise, +): Promise { + const started = deferred() + const release = deferred() + await withFakeCompanions( + (_path, index) => async () => ({ + name: `test-invariant-${index}`, + inject: ['invariants'], + async apply() { + if (index === 0) { + started.resolve() + await release.promise + } + return () => {} + }, + }), + () => run({ started: started.promise, release: release.resolve }), + ) +} + describe('global test invariant host', () => { it('uses one exhaustive topology to reserve every package name with enabled checks', async () => { const ctx = new Context() @@ -132,6 +171,106 @@ describe('global test invariant host', () => { expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false) }) + it('preserves config validation failures without starting the rejected plugin', async () => { + const ctx = new Context() + const apply = vi.fn(invalidConfigApply) + const plugin = { + apply, + Config: requiredConfig(), + } + + const fiber = ctx.plugin(plugin, {}) + const firstError = await rejectionOf(fiber) + expectRequiredConfigValidation(firstError) + await ctx.plugin(TestInvariantProbe) + const secondError = await rejectionOf(fiber) + expect(secondError).toBe(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + }) + + it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { + const ctx = new Context() + const apply = vi.fn(invalidConfigApply) + let disposeQueuedReadiness: (() => void) | undefined + const plugin = { + apply, + Config: z.intersect([ + queuedReadinessConfig(ctx, (dispose) => { + disposeQueuedReadiness = dispose + }), + requiredConfig(), + ]), + } + + const fiber = ctx.plugin(plugin, {}) + const firstError = await rejectionOf(fiber) + expectRequiredConfigValidation(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + + await started + if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') + disposeQueuedReadiness() + release() + await ctx.plugin(TestInvariantProbe) + + const secondError = await rejectionOf(fiber) + expect(secondError).toBe(firstError) + expect(fiber.state).toBe(FiberState.DISPOSED) + expect(apply).not.toHaveBeenCalled() + }, + ) + }) + + it('retains a valid plugin failure when readiness wins the initial-probe race', async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { + const ctx = new Context() + const failure = new Error('valid plugin apply failed') + const applied = deferred() + const apply = vi.fn(function validConfigApply() { + applied.resolve() + throw failure + }) + let disposeQueuedReadiness: (() => void) | undefined + const plugin = { + apply, + Config: queuedReadinessConfig(ctx, (dispose) => { + disposeQueuedReadiness = dispose + }), + } + + const fiber = ctx.plugin(plugin, {}) + const returnedError = rejectionOf(fiber) + try { + await Promise.all([started, applied.promise]) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + + if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') + Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) + disposeQueuedReadiness() + release() + + expect(await returnedError).toBe(failure) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + } finally { + Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) + disposeQueuedReadiness?.() + release() + } + }, + ) + }) + it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => { const delayedStarted = deferred() const releaseDelayed = deferred() @@ -208,12 +347,8 @@ describe('global test invariant host', () => { }) it('holds plugins registered on a root-derived context until companion readiness', async () => { - const delayedStarted = deferred() - const releaseDelayed = deferred() - - await withFakeCompanions( - delayedCompanions(delayedStarted, releaseDelayed), - async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { const ctx = new Context() const rootApply = vi.fn(function rootApply() {}) const derivedApply = vi.fn(function derivedApply() {}) @@ -224,7 +359,7 @@ describe('global test invariant host', () => { const rootFiber = ctx.plugin(rootApply) const derivedFiber = derived.plugin(derivedApply) - await delayedStarted.promise + await started await Promise.resolve() await Promise.resolve() expect(rootApply).not.toHaveBeenCalled() @@ -233,7 +368,7 @@ describe('global test invariant host', () => { [TEST_INVARIANT_READY_SERVICE]: null, }) - releaseDelayed.resolve() + release() await Promise.all([rootFiber, derivedFiber]) expect(rootFiber.state).toBe(FiberState.ACTIVE) expect(derivedFiber.state).toBe(FiberState.ACTIVE) @@ -244,12 +379,8 @@ describe('global test invariant host', () => { }) it('holds a child registered externally on a pending target context', async () => { - const delayedStarted = deferred() - const releaseDelayed = deferred() - - await withFakeCompanions( - delayedCompanions(delayedStarted, releaseDelayed), - async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { const ctx = new Context() const targetApply = vi.fn(function targetApply() {}) const childApply = vi.fn(function childApply() {}) @@ -257,7 +388,7 @@ describe('global test invariant host', () => { const targetFiber = ctx.plugin(targetApply) const childFiber = targetFiber.ctx.plugin(childApply) - await delayedStarted.promise + await started await Promise.resolve() await Promise.resolve() expect(targetFiber.state).toBe(FiberState.PENDING) @@ -268,7 +399,7 @@ describe('global test invariant host', () => { [TEST_INVARIANT_READY_SERVICE]: null, }) - releaseDelayed.resolve() + release() await Promise.all([targetFiber, childFiber]) expect(targetFiber.state).toBe(FiberState.ACTIVE) expect(childFiber.state).toBe(FiberState.ACTIVE) @@ -309,22 +440,18 @@ describe('global test invariant host', () => { ) it('disposes a pending target without waiting for companion readiness', async () => { - const delayedStarted = deferred() - const releaseDelayed = deferred() - - await withFakeCompanions( - delayedCompanions(delayedStarted, releaseDelayed), - async () => { + await withDelayedFirstCompanion( + async ({ started, release }) => { const ctx = new Context() const targetApply = vi.fn(function targetApply() {}) const targetFiber = ctx.plugin(targetApply) - await delayedStarted.promise + await started await expect(targetFiber.dispose()).resolves.toBeUndefined() expect(targetFiber.state).toBe(FiberState.DISPOSED) expect(targetApply).not.toHaveBeenCalled() - releaseDelayed.resolve() + release() await targetFiber expect(targetApply).not.toHaveBeenCalled() }, diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 8ebf7a6243..9705f590de 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -75,7 +75,9 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge if (hasBarrierOwner(host, this.ctx)) { return originalPlugin.call(this, plugin, config, getOuterStack) } - if (callback === undefined) return originalPlugin.call(this, plugin, config, getOuterStack) + if (callback === undefined) { + return originalPlugin.call(this, plugin, config, getOuterStack) + } const fiber = originalPlugin.call( this, @@ -83,8 +85,9 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge config, getOuterStack, ) + const initiallyPending = fiber.ctx.fiber.state === FiberState.PENDING host.barrierOwners.add(fiber.ctx.fiber) - return joinInvariantStartup(fiber, host.ready) + return joinInvariantStartup(fiber, host.ready, initiallyPending) } /** @@ -204,8 +207,25 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi } } -function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise): PluginFiber { - const readiness = invariantReady.then(() => fiber.await()) +function joinInvariantStartup( + fiber: PluginFiber, + invariantReady: Promise, + disposeInitialFailure = false, +): PluginFiber { + // RegistryService returns a thenable wrapper whose context still points to + // the raw Fiber. Calling inherited await() on the wrapper would return and + // assimilate that thenable, accidentally following later plugin startup. + const rawFiber = fiber.ctx.fiber + const initialized = disposeInitialFailure + ? rawFiber.await().catch(async (error: unknown) => { + // Config validation is the only failure recorded while a gated fiber + // is initially PENDING. Dispose it even if queued readiness publication + // changes its state before this rejection handler runs. + await rawFiber.dispose() + throw error + }) + : Promise.resolve() + const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await()) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined From 3d6decedba498ac6966c6fd4e67d9af66435d7b5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:59:22 +0800 Subject: [PATCH 44/47] chore(agent-loop): flag request context code smell --- packages/core/agent-loop/src/agent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 1a176e8b62..07f87419a6 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -680,6 +680,7 @@ export class ReactLoopAgent implements Agent { session.append('request/header', { header, reason: 'change' }) } + // TODO: This looks like code smell. // Context metadata for the route this request resolved to, recorded from the same // registration-bound lookup that prepared the call (no second resolve). // A route with unknown capacity is still recorded so it clears any older From 21cd24117726c67ac0a1a74259dac93fac581376 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 16:04:45 +0800 Subject: [PATCH 45/47] refactor(ui-conversation): extract shared toolview row-status helpers SearchRow (this PR) and FileMutationRow (landed on master) independently carry byte-identical rowStateStatus + rowResultText helpers, which the duplication gate flags once both are present. Extract both into contract/toolview-status.ts and consume them from both rows, deleting the clone rather than nudging it under the threshold. --- .../src/client/contract/toolview-status.ts | 45 +++++++++++++++++++ .../client/toolviews/file-mutation-row.tsx | 36 ++------------- .../src/client/toolviews/search-row.tsx | 43 +++--------------- 3 files changed, 53 insertions(+), 71 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/contract/toolview-status.ts diff --git a/packages/client/ui-conversation/src/client/contract/toolview-status.ts b/packages/client/ui-conversation/src/client/contract/toolview-status.ts new file mode 100644 index 0000000000..043fe06522 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/toolview-status.ts @@ -0,0 +1,45 @@ +// Shared toolview-row helpers for the keyed rows whose card is resident below a +// summary (SearchRow, FileMutationRow): the visually hidden run-state label and +// the flattened settled-result text for the fallback arm a card cannot render. +// Both are pure functions of a frozen call slice — no chat-domain imports — so a +// row stays a thin ToolRowProps consumer. + +import type { ToolRowProps } from './slots.ts' +import type { ToolRowState } from './tool-call-model.ts' + +/** + * Visually hidden run-state label for a row's leading `StateDot` (which is + * `aria-hidden`), so assistive technology still announces the state. Returns + * null for the settled-ok state, which needs no spoken label. + * @param state - the row's run state. + * @returns the label, or null when none is needed. + */ +export function rowStateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * A settled result's text, flattened from its content blocks, for the fallback + * arm a keyed row shows when its card cannot render the result — an errored call + * (the tool emits no result view on error) or a settled call with no card view + * (a nested `run_code` sub-dispatch, a legacy generic result). The keyed row owns + * the render slot, so without this the model-facing text would have nowhere to + * go. Falls back to the error name/code when the result carries no text block. + * @param block - the frozen call slice. + * @returns the result text, or null for a running call or an empty result. + */ +export function rowResultText(block: ToolRowProps['block']): string | null { + if (!('kind' in block)) return null + const parts: string[] = [] + for (const item of block.content) { + if (item.type === 'text') parts.push(item.text) + } + if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) + const text = parts.join('\n') + return text === '' ? null : text +} diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx index 323a73e77c..c7dba58c5b 100644 --- a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -18,6 +18,7 @@ import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client- import type { ToolRowProps } from '../contract/slots.ts' import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts' import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts' import css from './file-mutation-row.module.css' function leadingFor(state: ToolRowState) { @@ -29,37 +30,6 @@ function leadingFor(state: ToolRowState) { } } -/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ -function stateStatus(state: ToolRowState): string | null { - switch (state) { - case 'running': return '运行中' - case 'error': return '失败' - case 'stopped': return '已停止' - default: return null - } -} - -/** - * A settled result's text, flattened from its content blocks, for the arm that - * shows a failure the diff card cannot: write/edit return `undefined` from - * `presentResult` on `result.isError`, so an errored mutation has no diff card, - * and the keyed row is not a details-panel target. Without this the failure — - * an `old_string` that did not match, a permission denial — would read as a bare - * red dot with the model-facing error text nowhere on screen. - * @param block - the frozen call slice. - * @returns the result text, or null for a running call or an empty result. - */ -function errorText(block: ToolRowProps['block']): string | null { - if (!('kind' in block)) return null - const parts: string[] = [] - for (const item of block.content) { - if (item.type === 'text') parts.push(item.text) - } - if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) - const text = parts.join('\n') - return text === '' ? null : text -} - /** * File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome, * with the applied diff resident below it. The summary is a path link (a file @@ -70,11 +40,11 @@ function errorText(block: ToolRowProps['block']): string | null { export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) { const model = toolRowModel(toolName, block, cwd) const diff = diffCardModel(block) - const status = stateStatus(model.state) + const status = rowStateStatus(model.state) const filePath = model.filePath // An errored mutation has no diff card (presentResult returns undefined on // isError); surface its result text so the failure is more than a red dot. - const failure = diff === null && model.state === 'error' ? errorText(block) : null + const failure = diff === null && model.state === 'error' ? rowResultText(block) : null return (
diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx index 8c0181ba78..5ea72e4a25 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx @@ -17,6 +17,7 @@ import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-cli import type { ToolRowProps } from '../contract/slots.ts' import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../contract/search-card-model.ts' import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts' import css from './search-row.module.css' /** Leading-slot glyph substitution: the search icon yields to the terminal @@ -30,40 +31,6 @@ function leadingFor(state: ToolRowState) { } } -/** Visually hidden status — StateDot is aria-hidden; assistive technology needs a text label. */ -function stateStatus(state: ToolRowState): string | null { - switch (state) { - case 'running': return '运行中' - case 'error': return '失败' - case 'stopped': return '已停止' - default: return null - } -} - -/** - * A settled result's text, flattened from its content blocks, for the arm that - * shows a result the search card cannot. Two cases reach it: an errored search - * (grep/glob emit no `presentResult` on an error result, so an errored search - * has no card), and a settled call whose result view is not a search card at all - * — a nested `run_code` sub-dispatch (the backend computes no presentationMeta - * for it, so `resultView` is null) or a legacy generic result. In both the keyed - * SearchRow owns the render slot, so without this arm the model-facing text would - * have nowhere to go: an errored search would read as a bare red dot, and a - * successful cardless result would show only its summary with its content lost. - * @param block - the frozen call slice. - * @returns the result text, or null for a running call or an empty result. - */ -function errorText(block: ToolRowProps['block']): string | null { - if (!('kind' in block)) return null - const parts: string[] = [] - for (const item of block.content) { - if (item.type === 'text') parts.push(item.text) - } - if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) - const text = parts.join('\n') - return text === '' ? null : text -} - /** * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the * completed search's card resident below it, and — when the result was capped — @@ -75,15 +42,15 @@ function errorText(block: ToolRowProps['block']): string | null { export function SearchRow({ toolName, block }: ToolRowProps) { const model = toolRowModel(toolName, block) const search = searchCardModel(block) - const status = stateStatus(model.state) + const status = rowStateStatus(model.state) // A settled call with no search card — an errored search (grep/glob emit no // result view on error), a successful nested run_code sub-dispatch, or a // legacy generic result — has its model-facing text nowhere else to go, since // the keyed SearchRow owns this render slot. Surface it as the fallback body. - // A running call ('kind' absent) has no result to flatten; errorText returns - // null for it, so the arm stays closed until settle. + // A running call ('kind' absent) has no result to flatten; rowResultText + // returns null for it, so the arm stays closed until settle. const settled = 'kind' in block - const fallback = search === null && settled ? errorText(block) : null + const fallback = search === null && settled ? rowResultText(block) : null return (
From ce0aa90c1e1080fe3c85b9b91d02475b773c64ed Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 02:00:01 +0800 Subject: [PATCH 46/47] feat(cli): dsh --dump-config / --dump-default-config print the composed tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dsh --dump-config and dsh web --dump-config compose the shipped base, the surface overlay, and the --config or personal overlay — exactly the layers that surface boots — and print the entry list as YAML without booting; --dump-default-config stops at the surface overlay so the two outputs diff to precisely the user layer's effect. The dump shares the mounting code: the vendored include exports its patch algorithm as applyEntryPatches() and its !!js dialect as entryListSchema (logged in vendor/README.md), dsh-app-boot's renderConfigDump() composes and renders through both (and now imports the dialect instead of duplicating it), and the CLI adds a thin dump-config mode. !!js expressions print verbatim; unmatched patches warn on stderr; boot-only flags are rejected alongside the dump flags. (cherry picked from commit 1fdbebfa8a5dc7df840d53666320064a7e3dae59) --- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 4 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 4 +- .../2026-07-30-dsh-dump-config.i18n.yaml | 6 + .../feature/2026-07-30-dsh-dump-config.md | 31 +++ .../feature/2026-07-30-dsh-dump-config.zh.md | 31 +++ apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 4 +- apps/cli/README.zh.md | 4 +- apps/cli/src/args.ts | 87 +++++++- apps/cli/src/bin.ts | 5 + apps/cli/src/dump-config.ts | 61 ++++++ apps/cli/tests/args.spec.ts | 23 +++ apps/cli/tests/built-bin.e2e.ts | 75 ++++++- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 1 + packages/ui/app-boot/README.zh.md | 1 + packages/ui/app-boot/src/index.ts | 153 ++++++++++++-- .../ui/app-boot/tests/config-dump.spec.ts | 187 ++++++++++++++++++ vendor/README.md | 1 + vendor/include/src/index.ts | 172 +++++++++------- 21 files changed, 756 insertions(+), 106 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md create mode 100644 apps/cli/src/dump-config.ts create mode 100644 packages/ui/app-boot/tests/config-dump.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 11674d7747..9e8573a79d 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: 3331e36c86002d91fb272868268707fed014d01a -2026-07-20-dsh-cli-personal-config.zh.md: 172d84b075ed7ecc127c317b47e30581db67f89a +2026-07-20-dsh-cli-personal-config.md: 259c3865a9edcbc77949a9fe401af9a77e1e32c4 +2026-07-20-dsh-cli-personal-config.zh.md: 8f7c15c3c683cc855c6e3b704bfde8f87d4009d2 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 3331e36c86..259c3865a9 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -39,9 +39,9 @@ Hot-reload interplay: the include re-applies its `patches` on every config re-re ## Consequences - `dsh` from any directory (and `pnpm run demo:tui`) boots the personal provider/model with zero repo changes; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. -- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings are the only diagnostics. +- Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](2026-07-30-dsh-dump-config.md) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. -- `dsh-app-boot` depends on `js-yaml` (plus a load-only copy of the include's `!!js` YAML type) and, like `apps/cli`, on `@deepseek-ai/dsh-paths` for `resolveDshHome`. +- `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. - When PR #443 lands, `apps/cli/src/bin.ts`'s dispatch chain and `apps/cli/package.json`'s dependency list conflict textually; both resolve as unions (their `web`/`-p` branches plus our default-TUI branch). ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 172d84b075..8f7c15c3c6 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -39,9 +39,9 @@ PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录 ## Consequences - 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`)即可零仓库改动地使用个人提供方/模型;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 -- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;loader 的「配置项未找到/名称不匹配」警告是仅有的诊断。 +- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](2026-07-30-dsh-dump-config.md)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 -- `dsh-app-boot` 依赖 `js-yaml`(外加一份只用于加载的 include `!!js` YAML 类型副本),并与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 +- `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 - PR #443 落地时,`apps/cli/src/bin.ts` 的分发链与 `apps/cli/package.json` 的依赖列表会产生文本冲突;两者都按并集解决(他们的 `web`/`-p` 分支加上我们的默认 TUI 分支)。 ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml new file mode 100644 index 0000000000..0cd2549e55 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-30-dsh-dump-config.md +2026-07-30-dsh-dump-config.md: bc6504541c7868bad019a1bcd9f551435109e4c6 +2026-07-30-dsh-dump-config.zh.md: 5e173305a6cd03de3db4c763f26eeda6fba68ec7 diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md new file mode 100644 index 0000000000..bc6504541c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.md @@ -0,0 +1,31 @@ +# Agent Note: dsh --dump-config prints the composed config tree + +Status: implemented + +English | [中文](2026-07-30-dsh-dump-config.zh.md) + +## Problem + +The booted tree is a composition the user never sees: the shipped base, a surface overlay, and the `--config` or personal `~/.dsh/config.yaml` overlay apply as sibling patch lists where each id-targeted patch replaces the row's whole `config` and an unmatched id only warns. Debugging a misbehaving personal overlay (a restated field dropped, a row id typo, a patch applying to the wrong surface) required mentally replaying the patch algorithm across three files. There was no way to see the effective tree or to diff it against the shipped defaults. + +## Decision + +`dsh --dump-config` and `dsh web --dump-config` print the composed entry list — base, surface overlay, then the `--config` or personal overlay, exactly the layers that surface's boot assembles — as YAML on stdout and exit without booting. `dsh --dump-default-config` / `dsh web --dump-default-config` stop at the surface overlay, so diffing the two outputs shows precisely what the user layer changes. + +The dump cannot drift from what boots because it shares the mounting code: the vendored include exports its patch algorithm as the pure `applyEntryPatches(data, patches, warn)` (the private `applyPatches` method now delegates to it) and its `!!js` YAML dialect as `entryListSchema`; `dsh-app-boot`'s `renderConfigDump()` composes labeled layers and renders through both, and `apps/cli/src/dump-config.ts` is a thin surface-selection wrapper. `!!js` expressions print verbatim and unevaluated — the dump shows composition, not one process's environment — and a patch whose target row is absent goes to stderr with its layer label, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, web CLI-flag patches, the frontend dist path) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) and each other, and `--dump-default-config` takes no `--config`. + +Each run of same-provenance rows is preceded by a `# ==` comment naming the file that contributed the rows and the layers that patched them (`# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows which section comes from which file while remaining one loadable YAML document. Composition is one flattened `applyEntryPatches` call over all layers — boot's exact call shape, so even patch-visibility corner cases (a later layer targeting a group child that a plain `config` replacement introduced, invisible to the single-pass id index) compose identically; applying one call per layer would rebuild the index between layers and print a tree boot never mounts. Provenance is derived from single-call prefix snapshots (base + layers 1..k) diffed positionally: the patch algorithm only rewrites rows in place or appends, so a top-level index identifies one row across snapshots, and a layer counts as having patched a row when adding it changed that row (config replacement, disable, group insert). Patch lists are cloned per snapshot because `applyEntryPatches` pushes `insert` rows by reference from the patch list. + +`dsh-app-boot` previously duplicated the include's `!!js` YAML type for patch parsing; it now imports `entryListSchema`, so the dialect has one owner. + +## Alternatives considered + +**Boot the tree and dump `ctx.loader.entries()`.** Rejected: booting evaluates `!!js` expressions (leaking one machine's environment into the printed config), starts adapters and sessions as side effects, requires a TTY-independent teardown path, and is slow. The dump is for debugging composition, which is a pure function of the files. + +**Reimplement the patch merge in the CLI.** Rejected: a second implementation of `applyPatches` would silently drift from the vendored include — the exact failure mode the feature exists to debug. Exporting the include's own algorithm costs one logged vendor modification and guarantees identity. + +**A `/dump-config` TUI command instead of flags.** Rejected as the only form: the primary use is a piped `dsh --dump-config | diff - <(dsh --dump-default-config)` style workflow, which needs a boot-free non-TTY surface. A TUI command can be added later over the same `renderConfigDump`. + +## Consequences + +Config debugging becomes one command instead of mental patch replay, and support can ask for `--dump-config` output. The vendored include carries one more logged local modification (the `applyEntryPatches`/`entryListSchema` exports; behavior-preserving for mounting) to re-apply on upstream sync. Provenance tracking re-composes one prefix snapshot per layer and diffs rows by JSON stringify, so the dump does extra work proportional to layers² × rows; that cost lives only in the boot-free dump path. `renderConfigDump` is unit-tested for layer ordering, verbatim `!!js` round-tripping, provenance separators and grouping, labeled unmatched-patch warnings, and loud read/parse/shape failures; the built-bin e2e drives all four flag forms through `lib/bin.js` including the personal-overlay layer, its provenance label, and its stderr warning. diff --git a/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md new file mode 100644 index 0000000000..5e173305a6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-dsh-dump-config.zh.md @@ -0,0 +1,31 @@ +# Agent Note: dsh --dump-config 打印合成后的配置树 + +Status: implemented + +[English](2026-07-30-dsh-dump-config.md) | 中文 + +## Problem + +启动的配置树是一份用户从未见过的合成结果:已交付的基础配置、界面覆盖层,以及 `--config` 或个人 `~/.dsh/config.yaml` 覆盖层作为同级补丁列表依次应用,其中每个按 id 定向的补丁替换目标行的整个 `config`,未匹配的 id 只产生警告。调试一个行为异常的个人覆盖层(漏掉需要重述的字段、行 id 拼错、补丁应用到了错误的界面)需要在脑中跨三个文件重放补丁算法。既没有办法看到生效的树,也没有办法把它与已交付的默认值做 diff。 + +## Decision + +`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的条目列表——基础配置、界面覆盖层、再叠 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西。`dsh --dump-default-config` / `dsh web --dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。 + +dump 不可能与实际启动漂移,因为它复用挂载代码:vendored include 把补丁算法导出为纯函数 `applyEntryPatches(data, patches, warn)`(私有的 `applyPatches` 方法现在委托给它),并把 `!!js` YAML 方言导出为 `entryListSchema`;`dsh-app-boot` 的 `renderConfigDump()` 通过这两者对带标签的层完成合成与渲染,`apps/cli/src/dump-config.ts` 只是选择界面的薄封装。`!!js` 表达式原样打印、不求值——dump 展示的是合成结果,不是某个进程的环境——目标行不存在的补丁会连同其层标签报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、web 的 CLI 标志补丁、前端 dist 路径)是每次调用的事实,位于配置树之外,不会出现。dump 标志拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)且两个 dump 标志互斥,`--dump-default-config` 不接受 `--config`。 + +每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献这些行的文件以及修补过它们的层(`# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示每一节来自哪个文件,又仍是一份可加载的 YAML 文档。合成是对所有层展平后的一次 `applyEntryPatches` 调用——与启动的调用形状完全一致,因此即便是补丁可见性的边角情况(后一层定位到前一层通过普通 `config` 替换引入的组内子项,而单遍 id 索引看不到它)也与启动合成完全相同;若按层各调用一次,会在层与层之间重建索引,打印出一棵启动从不挂载的树。来源从单次调用的前缀快照(基础 + 第 1..k 层)按位置 diff 得出:补丁算法只会原地改写行或在末尾追加,因此顶层索引在各快照之间标识同一行;加入某层后该行发生变化(替换 config、禁用、组内插入)即视为该层修补了这一行。每个快照都会克隆补丁列表,因为 `applyEntryPatches` 会把 `insert` 行按引用从补丁列表推入结果。 + +`dsh-app-boot` 之前为解析补丁复制了 include 的 `!!js` YAML 类型;现在改为导入 `entryListSchema`,方言只有一个归属者。 + +## Alternatives considered + +**启动整棵树后 dump `ctx.loader.entries()`。** 拒绝:启动会求值 `!!js` 表达式(把某台机器的环境泄漏进打印的配置)、以副作用启动适配器和会话、需要独立于 TTY 的拆卸路径,而且慢。dump 是用来调试合成的,而合成是那些文件的纯函数。 + +**在 CLI 里重新实现补丁合并。** 拒绝:`applyPatches` 的第二个实现会与 vendored include 悄然漂移——这恰恰是该功能要调试的失败模式。导出 include 自己的算法只花费一条记录在案的 vendor 修改,却保证了同一性。 + +**用 `/dump-config` TUI 命令代替标志。** 作为唯一形式被拒绝:主要用法是 `dsh --dump-config | diff - <(dsh --dump-default-config)` 这类管道工作流,需要免启动、非 TTY 的界面。之后可以在同一个 `renderConfigDump` 之上再加 TUI 命令。 + +## Consequences + +配置调试从脑中重放补丁变成一条命令,支持工作也可以直接索要 `--dump-config` 输出。vendored include 多出一条记录在案的本地修改(导出 `applyEntryPatches`/`entryListSchema`;对挂载行为无影响),上游同步时需重新应用。来源追踪为每层重新合成一次前缀快照并按 JSON stringify 对行做 diff,因此 dump 有与层数²×行数成正比的额外开销;该开销只存在于免启动的 dump 路径。`renderConfigDump` 的单元测试覆盖层叠顺序、`!!js` 原样往返、来源分隔与分组、带标签的未匹配补丁警告,以及读取/解析/形状失败的大声报错;built-bin e2e 通过 `lib/bin.js` 驱动全部四种标志形式,包括个人覆盖层、其来源标签及其 stderr 警告。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 1a6a21aac6..33fed58119 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.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 apps/cli/README.md -README.md: fc5bce195fb10872c605cada1bcb3ed79380265c -README.zh.md: 2fb1272231abb02e145e5c9925362de27307ca86 +README.md: cf038ad19c631721c7b3182ffe83e75e3837d9ba +README.zh.md: c790f973a9ab0071253ab161bd8bc7835ebb2e02 diff --git a/apps/cli/README.md b/apps/cli/README.md index fc5bce195f..cf038ad19c 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -3,7 +3,7 @@ English | [中文](README.zh.md) -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`, `--dump-config`, `--dump-default-config`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: @@ -17,6 +17,8 @@ The TUI surface: `dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. +`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`. + The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 2fb1272231..c790f973a9 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -3,7 +3,7 @@ [English](README.md) | 中文 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`、`--dump-config`、`--dump-default-config`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: @@ -17,6 +17,8 @@ TUI 界面: `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 +`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。 + Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index a1f0fff4c4..ac74b9468d 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -23,6 +23,22 @@ interface TuiInvocation { resume?: string } +/** + * Print the composed config tree and exit, without booting: `--dump-config` + * composes the shipped base, the surface overlay, and the `--config` or + * personal overlay — exactly the layers that surface would boot; + * `--dump-default-config` stops at the surface overlay (the shipped tree, no + * user layer). + */ +interface DumpConfigInvocation { + mode: 'dump-config' + surface: 'tui' | 'web' + /** Omit the `--config`/personal layer and print only the shipped composition. */ + defaultOnly: boolean + /** The `--config` overlay to compose instead of the personal one. */ + config?: string +} + /** Headless one-shot: `dsh -p "task"`. */ interface HeadlessInvocation { mode: 'headless' @@ -69,6 +85,7 @@ interface WebInvocation { /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ export type DshInvocation = | TuiInvocation + | DumpConfigInvocation | HeadlessInvocation | MetaInvocation | SkillSessionInvocation @@ -82,6 +99,34 @@ interface WebOptions { dev?: boolean workspaceRoot?: string trustedHost?: string[] + dumpConfig?: boolean + dumpDefaultConfig?: boolean +} + +/** + * Resolve the two dump flags for one surface, or return `undefined` when + * neither was passed. Both flags together are contradictory (one includes the + * user layer, the other excludes it) and fail loud through `error`. + */ +function resolveDump( + surface: 'tui' | 'web', + options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean }, + error: (message: string) => never, +): DumpConfigInvocation | undefined { + if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined + if (options.dumpConfig === true && options.dumpDefaultConfig === true) { + error('error: --dump-config and --dump-default-config are mutually exclusive') + } + const defaultOnly = options.dumpDefaultConfig === true + if (defaultOnly && options.config !== undefined) { + error('error: --dump-default-config prints the shipped tree and takes no --config') + } + return { + mode: 'dump-config', + surface, + defaultOnly, + ...options.config !== undefined && { config: options.config }, + } } /** @@ -135,7 +180,26 @@ Examples: .option('--resume ', 'continue a past session by id') .option('--config ', 'apply this overlay of loader patches instead of the personal one') .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') - .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => { + .option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit') + .option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit') + .action((options: { + config?: string + configReplace?: string + prompt?: string + resume?: string + dumpConfig?: boolean + dumpDefaultConfig?: boolean + }) => { + const dump = resolveDump('tui', options, message => program.error(message)) + if (dump !== undefined) { + // The dump prints composition; a boot-only flag alongside it would be + // silently ignored, so reject the mix loud. + if (options.prompt !== undefined || options.resume !== undefined || options.configReplace !== undefined) { + program.error('error: --dump-config/--dump-default-config take none of -p/--prompt, --resume, or --config-replace') + } + resolved = dump + return + } if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to // run, and --config/--resume are TUI inputs that must not silently @@ -168,10 +232,18 @@ Examples: // a leaked config/prompt/resume option is a mistyped invocation that must fail // loud rather than silently run and drop the input. const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() + const parent = program.opts<{ + config?: string + configReplace?: string + prompt?: string + resume?: string + dumpConfig?: boolean + dumpDefaultConfig?: boolean + }>() if (parent.config !== undefined || parent.configReplace !== undefined - || parent.prompt !== undefined || parent.resume !== undefined) { - program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, or --resume`) + || parent.prompt !== undefined || parent.resume !== undefined + || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) { + program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, --resume, --dump-config, or --dump-default-config`) } } @@ -198,8 +270,15 @@ Examples: .option('--dev', 'developer mode: hot-reload the browser client') .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') + .option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit') + .option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit') .action((options: WebOptions) => { rejectParentOptions('web') + const dump = resolveDump('web', options, message => program.error(message)) + if (dump !== undefined) { + resolved = dump + return + } resolved = resolveWeb(options) }) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 6f7ae77c76..aeaaf42692 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -43,6 +43,11 @@ switch (invocation.mode) { await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) break } + case 'dump-config': { + const { runDumpConfig } = await import('./dump-config.ts') + runDumpConfig(invocation.surface, invocation.defaultOnly, invocation.config) + break + } case 'meta': { const { runMeta } = await import('./tui.ts') await runMeta() diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts new file mode 100644 index 0000000000..39a87c2dc8 --- /dev/null +++ b/apps/cli/src/dump-config.ts @@ -0,0 +1,61 @@ +/** + * `dsh --dump-config` / `dsh web --dump-config` — print the composed config + * tree without booting: the shipped base, the surface overlay, and (unless + * `--dump-default-config`) the `--config` or personal overlay, composed + * through the include's own patch algorithm so the printed tree is exactly + * what that surface would mount. `!!js` expressions print verbatim, + * unevaluated — the dump shows composition, not one process's environment. + * Launcher-provided boot-context values (session identity, CLI-flag patches) + * are per-invocation facts outside the config tree and do not appear. + * @module @deepseek-ai/dsh/dump-config + */ + +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + loadOverlayPatches, + loadPersonalPatches, + PERSONAL_CONFIG_FILENAME, + renderConfigDump, + type ConfigDumpLayer, +} from '@deepseek-ai/dsh-app-boot' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' + +const NAME = 'dsh' + +const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)) +const SURFACE_OVERLAYS = { + tui: fileURLToPath(new URL('../config/tui.cordis.yml', import.meta.url)), + web: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), +} as const + +/* v8 ignore start -- composition over the unit-tested renderConfigDump; the + built-bin e2e drives this path end to end */ +/** + * Print one surface's composed config tree to stdout, with a comment + * separator naming the file each section of rows comes from (and the layers + * that patched it). + * @param surface - which surface overlay to compose over the shared base. + * @param defaultOnly - stop at the surface overlay (no `--config`/personal layer). + * @param config - the `--config` overlay path composed instead of the personal + * one, or `undefined` to use `$DSH_HOME/config.yaml`. + */ +export function runDumpConfig(surface: 'tui' | 'web', defaultOnly: boolean, config?: string): void { + const overlay = SURFACE_OVERLAYS[surface] + const layers: ConfigDumpLayer[] = [ + { label: basename(overlay), patches: loadOverlayPatches(NAME, overlay) }, + ] + if (!defaultOnly) { + if (config === undefined) { + const personal = loadPersonalPatches(NAME) + // The personal file may be absent; the shipped layers still print. + if (personal !== undefined) { + layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal }) + } + } else { + layers.push({ label: config, patches: loadOverlayPatches(NAME, config) }) + } + } + process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers)) +} +/* v8 ignore stop */ diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 396a0e6ca5..cda7818e63 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -45,6 +45,29 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) + it('routes the dump flags per surface: composed with the user layer, or shipped only', () => { + expect(parse(['--dump-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false }) + expect(parse(['--dump-config', '--config', 'c.yml'])) + .toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: false, config: 'c.yml' }) + expect(parse(['--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'tui', defaultOnly: true }) + expect(parse(['web', '--dump-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false }) + expect(parse(['web', '--dump-config', '--config', 'w.yml'])) + .toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: false, config: 'w.yml' }) + expect(parse(['web', '--dump-default-config'])).toEqual({ mode: 'dump-config', surface: 'web', defaultOnly: true }) + // The two dump flags contradict each other; boot-only flags alongside a + // dump would be silently ignored; the shipped tree takes no user overlay. + expect(exitCode(['--dump-config', '--dump-default-config'])).toBe(1) + expect(exitCode(['--dump-default-config', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['--dump-config', '--resume', 's'])).toBe(1) + expect(exitCode(['--dump-config', '-p', 'task'])).toBe(1) + expect(exitCode(['--dump-config', '--config-replace', 'tree.yml'])).toBe(1) + expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) + expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1) + // A leaked dump flag on a subcommand that has none is a mistyped invocation. + expect(exitCode(['meta', '--dump-config'])).toBe(1) + expect(exitCode(['upgrade', '--dump-config'])).toBe(1) + }) + it('exits nonzero instead of silently starting fresh or dropping inputs', () => { // Empty resume/prompt would be swallowed downstream; --prompt mixed with // TUI inputs must not lose them. (Bad host/port are gated by the webserver diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index d5bcbfd378..c9e1b29969 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,8 +1,9 @@ -import { existsSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under @@ -22,13 +23,20 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ -async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { - const result = await execa(process.execPath, [dshBin], { +/** + * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + * + exit code. `env` isolates the Harness home for surfaces that read it. + */ +async function runBuiltBin( + args: readonly string[] = [], + env: Record = {}, +): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin, ...args], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, + env, }) if (result.timedOut) { throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) @@ -45,4 +53,61 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // The refusal happens before any plugin mounts: stdout stays silent. expect(stdout).toBe('') }, 30_000) + + describe('dsh --dump-config', () => { + let home: string + beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) }) + afterEach(() => { rmSync(home, { recursive: true, force: true }) }) + + it('prints the shipped TUI composition without booting or needing a TTY', async () => { + const { stdout, code, stderr } = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stderr).toBe('') + // Base rows composed with the TUI overlay's surface values, `!!js` + // expressions verbatim (unevaluated), and TUI-only inserted rows present. + expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'") + expect(stdout).toContain('model: deepseek-v4-pro') + expect(stdout).toContain('cwd: !!js process.cwd()') + expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'") + // Provenance comment separators name each section's source file. + expect(stdout).toContain('# == base.cordis.yml') + expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml') + expect(stdout).toContain('# == tui.cordis.yml') + }, 30_000) + + it('layers the personal overlay in --dump-config and reports an unmatched patch on stderr', async () => { + writeFileSync(join(home, 'config.yaml'), [ + '- id: agent-loop', + ' config:', + ' agents:', + ' - id: main', + ' provider: custom-provider', + ' model: custom-model', + '- id: only-on-web', + ' config:', + ' value: 1', + '', + ].join('\n')) + const { stdout, code, stderr } = await runBuiltBin(['--dump-config'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stdout).toContain('provider: custom-provider') + expect(stdout).not.toContain('model: deepseek-v4-pro') + // The personal layer appears in the patched row's provenance and the + // skipped-patch warning carries its label. + expect(stdout).toContain(`patched by tui.cordis.yml, ${join(home, 'config.yaml')}`) + expect(stderr).toContain('patch: entry "only-on-web" not found') + + // The shipped view ignores the personal overlay entirely. + const shipped = await runBuiltBin(['--dump-default-config'], { DSH_HOME: home }) + expect(shipped.stdout).not.toContain('custom-provider') + expect(shipped.stdout).toContain('model: deepseek-v4-pro') + }, 30_000) + + it('composes the web overlay for `dsh web --dump-config`', async () => { + const { stdout, code } = await runBuiltBin(['web', '--dump-config'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") + expect(stdout).not.toContain("name: '@deepseek-ai/dsh-tui'") + }, 30_000) + }) }) diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 684f322477..619a3ab81d 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.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 packages/ui/app-boot/README.md -README.md: 54f754842d9a6673ed6791b94656139f0f1be6a3 -README.zh.md: dd56084812e8241f0db24601ce2baeba51252d42 +README.md: 51bc5082512632dd493956b96c605aff47dc872e +README.zh.md: 644d3a3613a9516cb02881fac8ba7531bffa81eb diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 54f754842d..51bc508251 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -14,6 +14,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | +| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index dd56084812..644d3a3613 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -14,6 +14,7 @@ | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | +| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 015898f2cb..58b9d63bf8 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -12,7 +12,7 @@ import { basename, dirname, join, resolve } from 'node:path' import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import Include, { type PatchOptions } from '@cordisjs/plugin-include' +import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-paths' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -60,16 +60,12 @@ export function loadEnv( /** File inside the Harness home holding the personal loader overlay patches. */ export const PERSONAL_CONFIG_FILENAME = 'config.yaml' -// The include's YAML dialect: `!!js` scalars become expression nodes the -// Loader interpolates against each entry's context at mount time. Personal -// patches are parsed with the same schema so they may reference `process.env`. -// Load-only: this schema never dumps, so no `predicate`/`represent`. -const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { - kind: 'scalar', - resolve: data => typeof data === 'string', - construct: data => ({ __jsExpr: String(data) }), -}) -const personalPatchesSchema = yaml.JSON_SCHEMA.extend(jsExprType) +// The include's YAML dialect (`!!js` scalars become expression nodes the +// Loader interpolates against each entry's context at mount time), imported +// from the include itself so patch parsing and config dumping can never drift +// from what the include mounts. Personal patches share it so they may +// reference `process.env`. +const personalPatchesSchema = entryListSchema /** * Load the optional personal overlay patches (`config.yaml` under the Harness @@ -149,6 +145,141 @@ function parsePatchList( return parsed as PatchOptions[] } +/** One overlay patch list with the label provenance comments print for it. */ +export interface ConfigDumpLayer { + /** Source name shown in provenance comments (a file basename or path). */ + label: string + /** The layer's patches, from {@link loadOverlayPatches} / {@link loadPersonalPatches}. */ + patches: PatchOptions[] +} + +/** + * Compose the effective entry list exactly as `boot()` would mount it: parse + * the base config file with the include's entry-list dialect, apply every + * layer's patches as ONE flattened list through the include's own patch + * algorithm (`applyEntryPatches`) — the same single call `boot()` makes, so + * even patch-visibility corner cases (a later layer targeting a group child a + * plain config replacement introduced, which the single-pass id index never + * sees) compose identically — then render the result as YAML in the same + * dialect (`!!js` expressions print verbatim, unevaluated). + * + * Every run of rows with the same provenance is preceded by a `# ==` comment + * naming the file that contributed the rows and any layers that patched them, + * so the output stays a loadable YAML document while showing which section + * comes from which file. Provenance is derived from single-call prefix + * snapshots (base + layers 1..k), diffed positionally: the patch algorithm + * only rewrites rows in place or appends, so a top-level index identifies one + * row across snapshots, and a layer whose addition changes the row (config + * replacement, disable, group insert) is listed as having patched it. + * + * A patch that matches no row is reported through `warn` with its layer + * label, mirroring the Loader's boot-time warning. Earlier layers' patches + * see an identical preceding state in every snapshot that includes them, so + * each snapshot's warning list extends the previous one and the new tail + * belongs to the added layer. + * @param binName - the diagnostic prefix on read/parse errors. + * @param absoluteConfigPath - the base config file `boot()` would include. + * @param layers - overlay layers in application order (later wins). + * @param warn - sink for skipped-patch diagnostics; defaults to stderr. + * @returns the composed entry list rendered as a YAML document with + * provenance comment separators. + */ +export function renderConfigDump( + binName: string, + absoluteConfigPath: string, + layers: ConfigDumpLayer[], + warn: (line: string) => void = line => void process.stderr.write(`${line}\n`), +): string { + let content: string + try { + content = readFileSync(absoluteConfigPath, 'utf8') + } catch (error) { + throw new Error(`${binName}: failed to read config ${absoluteConfigPath}: ${String(error)}`) + } + let parsed: unknown + try { + parsed = yaml.load(content, { schema: entryListSchema }) + } catch (error) { + throw new Error(`${binName}: failed to parse config ${absoluteConfigPath}: ${String(error)}`) + } + if (!Array.isArray(parsed)) { + throw new Error(`${binName}: config ${absoluteConfigPath} must be a top-level YAML array of entries`) + } + const baseLabel = basename(absoluteConfigPath) + // The YAML boundary yields untyped rows; the include validates entry shape + // at mount, and the dump prints whatever the file holds, so `EntryOptions` + // here is structural trust in the same file `boot()` would include. + const base = parsed as Parameters[0] + // snapshot_k = ONE application of layers 1..k flattened — boot's exact call + // shape for that prefix. snapshot_N is therefore the mounted composition. + // The patches are cloned per call: applyEntryPatches detaches the entry + // list but pushes `insert` rows by reference from the patch list, so + // sharing patch objects across snapshot calls would leak a later + // snapshot's mutations into an earlier one's result. + const snapshot = (count: number, warnings: string[]): ReturnType => { + const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches)) + return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => { + // The include logs through cordis's printf-style logger (`%C` = code); a + // dump has no logger, so substitute inline for a plain line. + let index = 0 + warnings.push(message.replace(/%C/g, () => JSON.stringify(args[index++]))) + }) + } + let previous = base + let previousWarnings: string[] = [] + const provenance: { origin: string; patchedBy: string[] }[] = base.map(() => ({ origin: baseLabel, patchedBy: [] })) + let composed = base + for (let count = 1; count <= layers.length; count += 1) { + const layer = layers[count - 1] + /* v8 ignore next -- count iterates 1..length, so the slot exists */ + if (layer === undefined) continue + const warnings: string[] = [] + composed = snapshot(count, warnings) + for (const line of warnings.slice(previousWarnings.length)) { + warn(`${binName}: [${layer.label}] ${line}`) + } + const before = previous.map(entry => JSON.stringify(entry)) + for (let index = 0; index < composed.length; index += 1) { + if (index >= before.length) provenance.push({ origin: layer.label, patchedBy: [] }) + else if (JSON.stringify(composed[index]) !== before[index]) provenance[index]?.patchedBy.push(layer.label) + } + previous = composed + previousWarnings = warnings + } + return groupedDump(composed, provenance) +} + +/** Render the composed rows grouped under one provenance comment per contiguous run. */ +function groupedDump( + composed: readonly unknown[], + provenance: readonly { origin: string; patchedBy: string[] }[], +): string { + const lines: string[] = [] + let currentLabel: string | undefined + let group: unknown[] = [] + const flush = (): void => { + if (currentLabel === undefined || group.length === 0) return + lines.push(`# == ${currentLabel}`) + lines.push(yaml.dump(group, { schema: entryListSchema, noRefs: true }).trimEnd()) + group = [] + } + for (let index = 0; index < composed.length; index += 1) { + const record = provenance[index] + /* v8 ignore next -- provenance is index-aligned with composed by construction */ + if (record === undefined) continue + const label = record.patchedBy.length === 0 + ? record.origin + : `${record.origin}, patched by ${record.patchedBy.join(', ')}` + if (label !== currentLabel) { + flush() + currentLabel = label + } + group.push(composed[index]) + } + flush() + return lines.join('\n') + '\n' +} + /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. diff --git a/packages/ui/app-boot/tests/config-dump.spec.ts b/packages/ui/app-boot/tests/config-dump.spec.ts new file mode 100644 index 0000000000..99af81f2c2 --- /dev/null +++ b/packages/ui/app-boot/tests/config-dump.spec.ts @@ -0,0 +1,187 @@ +/** + * `renderConfigDump` behavior: the offline composition must equal what + * `boot()` mounts (same parser, same patch algorithm), print `!!js` + * expressions verbatim, separate provenance runs with comment lines while + * staying one loadable YAML document, and report skipped patches through + * `warn` instead of failing — mirroring the Loader's boot-time warning for a + * shared overlay whose row exists only on another surface. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import * as yaml from 'js-yaml' +import { entryListSchema } from '@cordisjs/plugin-include' +import { loadOverlayPatches, renderConfigDump } from '../src/index.ts' + +const NAME = 'dsh-test-bin' + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-')) + +function writeBase(dir: string): string { + const base = join(dir, 'base.yml') + writeFileSync(base, [ + '- id: shared', + ' name: ./noop.mjs', + ' config:', + ' value: base', + ' key: !!js process.env.DSH_DUMP_SPEC', + '- id: untouched', + ' name: ./noop.mjs', + '', + ].join('\n')) + return base +} + +describe('renderConfigDump', () => { + it('composes overlay layers in order, prints !!js verbatim, and labels each section with its provenance', () => { + const dir = tmp() + const base = writeBase(dir) + const surface = join(dir, 'surface.yml') + writeFileSync(surface, [ + '- id: shared', + ' config:', + ' value: surface', + ' key: !!js process.env.DSH_DUMP_SPEC', + '- insert:', + ' - id: surface-extra', + ' name: ./noop.mjs', + '', + ].join('\n')) + const personal = join(dir, 'personal.yml') + writeFileSync(personal, [ + '- id: surface-extra', + ' config:', + ' value: personal', + '', + ].join('\n')) + + const dump = renderConfigDump(NAME, base, [ + { label: 'surface.yml', patches: loadOverlayPatches(NAME, surface) }, + { label: 'personal.yml', patches: loadOverlayPatches(NAME, personal) }, + ], () => {}) + // Comments do not break loadability: the dump parses as one document + // equal to what boot() would mount. + const parsed = yaml.load(dump, { schema: entryListSchema }) as { + id: string + config?: Record + }[] + expect(parsed).toEqual([ + { + id: 'shared', + name: './noop.mjs', + config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } }, + }, + { id: 'untouched', name: './noop.mjs' }, + { id: 'surface-extra', name: './noop.mjs', config: { value: 'personal' } }, + ]) + // Unevaluated: the expression text round-trips as a !!js scalar. + expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC') + // Provenance separators: origin file, plus every layer that changed the + // row; an inserted row carries the inserting layer as its origin. + expect(dump).toContain('# == base.yml, patched by surface.yml') + expect(dump).toContain('# == base.yml\n- id: untouched') + expect(dump).toContain('# == surface.yml, patched by personal.yml\n- id: surface-extra') + expect(dump.indexOf('# == base.yml, patched by surface.yml')).toBeLessThan(dump.indexOf('# == base.yml\n- id: untouched')) + }) + + it('groups contiguous same-provenance rows under one separator', () => { + const dir = tmp() + const base = join(dir, 'base.yml') + writeFileSync(base, [ + '- id: a', + ' name: ./noop.mjs', + '- id: b', + ' name: ./noop.mjs', + '', + ].join('\n')) + const dump = renderConfigDump(NAME, base, [], () => {}) + expect(dump.match(/# == base\.yml/g)).toHaveLength(1) + expect(dump).toContain('# == base.yml\n- id: a') + }) + + it('composes all layers as one flattened patch list, exactly like boot()', () => { + // boot() flattens every layer into ONE applyEntryPatches call, whose id + // index sees inserted rows but NOT children introduced by a plain group + // `config` replacement. A per-layer composition would rebuild the index + // between layers and let the second layer patch that child — a tree the + // real boot never mounts. Pin the single-call semantics: the child patch + // is skipped (with the layer-labeled warning), matching boot. + const dir = tmp() + const base = join(dir, 'base.yml') + writeFileSync(base, [ + '- id: g', + ' name: ./group.mjs', + ' group: true', + ' config: []', + '', + ].join('\n')) + const warnings: string[] = [] + const dump = renderConfigDump(NAME, base, [ + { + label: 'a.yml', + patches: [{ id: 'g', config: [{ id: 'child', name: './noop.mjs', config: { v: 1 } }] }], + }, + { label: 'b.yml', patches: [{ id: 'child', config: { v: 2 } }] }, + ], line => void warnings.push(line)) + expect(warnings).toEqual([`${NAME}: [b.yml] patch: entry "child" not found`]) + const parsed = yaml.load(dump, { schema: entryListSchema }) as { + config?: { config?: { v?: number } }[] + }[] + expect(parsed[0]?.config?.[0]?.config?.v).toBe(1) + // The skipped layer did not change the row, so it is not in provenance. + expect(dump).toContain('# == base.yml, patched by a.yml\n- id: g') + expect(dump).not.toContain('b.yml\n- id: g') + }) + + it('reports a patch whose target row is absent through warn with its layer label and keeps composing', () => { + const dir = tmp() + const base = writeBase(dir) + const overlay = join(dir, 'overlay.yml') + writeFileSync(overlay, [ + '- id: only-on-another-surface', + ' config:', + ' value: ignored', + '- id: shared', + ' config:', + ' value: patched', + '', + ].join('\n')) + const warnings: string[] = [] + const dump = renderConfigDump( + NAME, base, + [{ label: 'overlay.yml', patches: loadOverlayPatches(NAME, overlay) }], + line => void warnings.push(line), + ) + expect(warnings).toEqual([`${NAME}: [overlay.yml] patch: entry "only-on-another-surface" not found`]) + const parsed = yaml.load(dump, { schema: entryListSchema }) as { config?: { value?: string } }[] + expect(parsed[0]?.config?.value).toBe('patched') + }) + + it('defaults its warn sink to one stderr line per skipped patch', () => { + const dir = tmp() + const base = writeBase(dir) + const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + renderConfigDump(NAME, base, [{ label: 'x.yml', patches: [{ id: 'absent', config: {} }] }]) + expect(write).toHaveBeenCalledWith(`${NAME}: [x.yml] patch: entry "absent" not found\n`) + } finally { + write.mockRestore() + } + }) + + it('fails loud on a missing, unparsable, or non-array base config', () => { + const dir = tmp() + expect(() => renderConfigDump(NAME, join(dir, 'absent.yml'), [], () => {})) + .toThrow(new RegExp(`^${NAME}: failed to read config `)) + const invalid = join(dir, 'invalid.yml') + writeFileSync(invalid, 'invalid: [unclosed\n') + expect(() => renderConfigDump(NAME, invalid, [], () => {})) + .toThrow(new RegExp(`^${NAME}: failed to parse config `)) + const scalar = join(dir, 'scalar.yml') + writeFileSync(scalar, 'id: not-a-list\n') + expect(() => renderConfigDump(NAME, scalar, [], () => {})) + .toThrow('must be a top-level YAML array of entries') + }) +}) diff --git a/vendor/README.md b/vendor/README.md index b140c057ab..2d3e1b6b05 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,6 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. +10. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 29f6a3a951..29c894401c 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -13,7 +13,15 @@ const JsExpr = new yaml.Type('tag:yaml.org,2002:js', { represent: (data) => data['__jsExpr'], }) -const schema = yaml.JSON_SCHEMA.extend(JsExpr) +/** + * The entry-list YAML dialect: `!!js` scalars round-trip as expression nodes + * the Loader evaluates at entry activation. Exported so config tooling + * (`dsh --dump-config`) parses and prints exactly the dialect this include + * mounts. + */ +export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr) + +const schema = entryListSchema const writable: Record = { '.json': 'application/json', @@ -23,6 +31,92 @@ const writable: Record = { const supported = new Set(Object.keys(writable)) +/** + * Apply patch lists to an entry list — THE patch semantics of this include, + * shared by mounting (`applyPatches`) and offline config tooling + * (`dsh --dump-config`) so a dump can never drift from what boots. The input + * is never mutated: patching shared entry objects would bake earlier patch + * values into the cached parse, so repeated application (config hot-reloads) + * could never revert a removed or changed patch. Inserted entries are indexed + * as they are added, so a later patch in the same list can target a row an + * earlier patch inserted. A patch that matches nothing warns and is skipped. + * @param data - the parsed entry list (JSON-safe plain data). + * @param patches - the patch list to apply, in order. + * @param warn - sink for skipped-patch diagnostics (printf-style, `%C` = code). + * @returns a detached entry list with every applicable patch applied. + */ +export function applyEntryPatches( + data: EntryOptions[], + patches: PatchOptions[] | undefined, + warn: (message: string, ...args: any[]) => void, +): EntryOptions[] { + if (!patches?.length) return [...data] + data = structuredClone(data) + + const entryMap = new Map() + const buildMap = (entries: EntryOptions[]) => { + for (const entry of entries) { + if (entry.id) entryMap.set(entry.id, entry) + if (entry.group && Array.isArray(entry.config)) { + buildMap(entry.config) + } + } + } + buildMap(data) + + for (const patch of patches) { + const { id, insert, name, ...overrides } = patch + + if (insert) { + if (id) { + const target = entryMap.get(id) + if (!target) { + warn('patch insert: entry %C not found', id) + continue + } + if (!target.group) { + warn('patch insert: entry %C is not a group', id) + continue + } + if (!Array.isArray(target.config)) target.config = [] + target.config.push(...insert) + } else { + data.push(...insert) + } + // Index what this patch added so a LATER patch in the same list can + // target it. Patch lists compose one layer per source (surface overlay, + // then `--config`, then the user's), and a layer must be able to + // configure or disable a row an earlier layer inserted; without this, + // inserted rows were silently unpatchable. + buildMap(insert) + continue + } + + if (!id) { + warn('patch: id is required for non-insert patches') + continue + } + + const target = entryMap.get(id) + if (!target) { + warn('patch: entry %C not found', id) + continue + } + + if (name && name !== target.name) { + warn('patch: name mismatch for %C (expected %C, got %C), skipping', id, target.name, name) + continue + } + + for (const [key, value] of Object.entries(overrides)) { + if (key === 'id') continue + target[key] = value + } + } + + return data +} + /** Runtime patch applied to entries loaded from an included config file. */ export interface PatchOptions { id?: string @@ -125,79 +219,9 @@ export class Include extends EntryTree { } private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { - // Always detach from the cached parse: patching shared entry objects would - // bake earlier patch values into `this.data`, so repeated application - // (config hot-reloads) could never revert a removed or changed patch. The - // supported extensions guarantee JSON-safe plain data, so `structuredClone` - // cannot throw here. - if (!patches?.length) return [...data] - data = structuredClone(data) - - const entryMap = new Map() - const buildMap = (entries: EntryOptions[]) => { - for (const entry of entries) { - if (entry.id) entryMap.set(entry.id, entry) - if (entry.group && Array.isArray(entry.config)) { - buildMap(entry.config) - } - } - } - buildMap(data) - - for (const patch of patches) { - const { id, insert, name, ...overrides } = patch - - if (insert) { - if (id) { - const target = entryMap.get(id) - if (!target) { - this.ctx.root.logger?.('loader').warn('patch insert: entry %C not found', id) - continue - } - if (!target.group) { - this.ctx.root.logger?.('loader').warn('patch insert: entry %C is not a group', id) - continue - } - if (!Array.isArray(target.config)) target.config = [] - target.config.push(...insert) - } else { - data.push(...insert) - } - // Index what this patch added so a LATER patch in the same list can - // target it. Patch lists compose one layer per source (surface overlay, - // then `--config`, then the user's), and a layer must be able to - // configure or disable a row an earlier layer inserted; without this, - // inserted rows were silently unpatchable. - buildMap(insert) - continue - } - - if (!id) { - this.ctx.root.logger?.('loader').warn('patch: id is required for non-insert patches') - continue - } - - const target = entryMap.get(id) - if (!target) { - this.ctx.root.logger?.('loader').warn('patch: entry %C not found', id) - continue - } - - if (name && name !== target.name) { - this.ctx.root.logger?.('loader').warn( - 'patch: name mismatch for %C (expected %C, got %C), skipping', - id, target.name, name, - ) - continue - } - - for (const [key, value] of Object.entries(overrides)) { - if (key === 'id') continue - target[key] = value - } - } - - return data + return applyEntryPatches(data, patches, (message, ...args) => { + this.ctx.root.logger?.('loader').warn(message, ...args) + }) } async* [Service.init]() { From f817c62baaca2dacc018a0df7928d15534887583 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 31 Jul 2026 17:01:16 +0800 Subject: [PATCH 47/47] test(web): refresh search stats golden after master merge --- apps/web/tests/snapshots/web-search-round/ui.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 514aaf6219..16f4159509 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -36,4 +36,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 0% Input 22 tok · Output 7 tok +- text: 1 turns · 2 steps Tool call {{duration}} Context 0% of 128K Cache hit 0% Input 22 tok · Output 7 tok