Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

This commit is contained in:
Yichen Jiang
2026-08-07 16:22:02 +08:00
109 changed files with 2801 additions and 320 deletions

View File

@@ -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-23-session-telemetry-otel-revival.md
2026-07-23-session-telemetry-otel-revival.md: a58598d8a956d47cb0cf6aa3e659f38314bc4b17
2026-07-23-session-telemetry-otel-revival.zh.md: 0deb0b81ce262db4fa4ae26076cd59756d628d20
2026-07-23-session-telemetry-otel-revival.md: f83128e8bf62e0718e59912c16c4e449855aaa1a
2026-07-23-session-telemetry-otel-revival.zh.md: bef3bd9cbc22f363b92880864651dcaa782fc1de

View File

@@ -12,9 +12,9 @@ Every deployment that wants harness sessions in an observability stack must hand
`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go and owns what leaves in them:
- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records.
- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: live adoption with cursor read-back and the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), buffer-free on-demand replay from the canonical log, the fixed first-chunk-per-(turn, step) projection, the live `agent/error` relay, and live dispose-time `shutdown` records.
- **The `telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten.
- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process.
- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. Its default `FULL` mode requires `exporter.url`; the later [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) adds `FEEDBACK_ONLY` and `DISABLED` delivery modes without moving the redaction or backend boundary, while [buffer-free feedback replay](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix.
The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly.
@@ -34,4 +34,4 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry
## Consequences
A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited.
A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack. `FULL` preserves that behavior by default, `FEEDBACK_ONLY` replays a canonical-log prefix when feedback is recorded, and `DISABLED` constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited.

View File

@@ -12,9 +12,9 @@ Status: implemented
`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责:
- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend``emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的接管、逐 append 的 firehose投影 → `structuredClone` → 脱敏 → `emit`,零 I/O、固定的每个轮次、步骤组合首分片投影、`agent/error` 转发以及 dispose资源释放时的 `shutdown` 记录。
- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend``emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的实时收养与逐 append 的 firehose投影 → `structuredClone` → 脱敏 → `emit`,零 I/O从权威日志进行的无缓冲按需回放、固定的每个(轮次、步骤)组合首分片投影、实时 `agent/error` 转发以及实时 dispose资源释放时的 `shutdown` 记录。
- **`telemetry/record` waterfall瀑布式事件** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何后端前必经此处seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本canonical log 永不改写。
- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端OTel JS SDK 日志流水线(`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP exporter`exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程
- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端OTel JS SDK 日志流水线(`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP exporter`exporter`/`processor` passthrough 原样配置。其默认 `FULL` 模式要求 `exporter.url`;后续的[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)增加了 `FEEDBACK_ONLY``DISABLED` 投递模式,但未移动脱敏或后端边界,而[无缓冲反馈回放](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本
边界公理保持不变harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK经 passthrough 配置——投递是尽力而为(崩溃时至多一次),两份 README 对此如实陈述。
@@ -34,4 +34,4 @@ Status: implemented
## 后果
部署方在 `cordis.yml` 加一个带 OTLP endpoint 的 Cordis 配置项即可把会话流接入任何 OTel 兼容体系;删除该配置项即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重新审议前明确不在范围内。
部署方在 `cordis.yml` 加一个带 OTLP endpoint 的 Cordis 配置项即可把会话流接入任何 OTel 兼容体系`FULL` 默认保留该行为,`FEEDBACK_ONLY` 在记录反馈时回放权威日志前缀,`DISABLED` 则不构造上报流水线;删除该配置项仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致包括文件内容与命令输出中内嵌的任何凭据因此跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重新审议前明确不在范围内。

View File

@@ -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-28-feedback-command.md
2026-07-28-feedback-command.md: 770c72954d66c9e26e03c47f99c261626e6ed09b
2026-07-28-feedback-command.zh.md: c8bd57ddb5e94bfb826ec40b771547ca9625c8e3

View File

@@ -0,0 +1,63 @@
# Agent Note: `/feedback` command
Status: implemented
English | [中文](2026-07-28-feedback-command.zh.md)
## Problem
A user who notices something wrong mid-session has nowhere to put that observation. Telling the model wastes a turn, changes the conversation the user was having, and buries the remark in derived history where no later reader can find it. Writing it outside the session loses the context that makes it meaningful — which session, at which point, against which work.
The capture surface has to be usable at the moment of annoyance, which rules out anything requiring the user to leave the interactive client, and it must not perturb the run in progress: no model tokens, no turn of work, no change to the request the user is waiting on.
## Decision
`@deepseek-ai/dsh-command-feedback` in `packages/feedback/command-feedback/` registers one global `feedback` command over `ctx.commands`. `/feedback <text>` acknowledges; bare or whitespace-only input returns a direct usage error. The handler is synchronous, injects only `commands`, and has no configuration.
The package declares the log-only `feedback/record { text }` session event and exports `recordFeedback(session, text)` as its command-independent producer. The producer discards surrounding whitespace, rejects an empty result, and appends exactly one event. `/feedback` delegates to it, so another UI, hook, or host integration can record the same domain fact without constructing a slash command.
`dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends start persistence's ordinary eager drain; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk.
Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md).
### Why feedback owns an event
Feedback is a domain fact, while `/feedback` is one trigger. Keeping the only payload in `feedback/record` lets later triggers use the same event and lets consumers select feedback without depending on command names or parsing command lifecycle records. Omitting `command/run.args` for this definition avoids two authoritative-looking copies of one human remark.
### Why the model never sees it
Feedback is about the session, not input to it. Injecting it as a user message would change the next model request, contradicting the requirement that recording not perturb the run, and would make the remark part of the conversation it comments on. `command/run` and `command/done` are absent from `SurfaceEventType`, so they cannot acquire a `surfaceOp` or enter derived history even by mistake.
### Verbatim text
Surrounding whitespace is discarded, but nothing else is parsed. `/feedback /plan felt slow` records `/plan felt slow`; the leading `/plan` is content, not a nested command. Control-word grammar of the kind `/goal` uses would make the corresponding literal feedback impossible to express, which is the opposite of what a capture surface is for.
### A new group
`packages/feedback/` is a new group because no existing one owns this. `goal/` is objective state, `session-title/` is titles, `core/` is the product spine. The group holds one producer package; cross-cutting consumers stay in their owning groups rather than forcing this one to grow.
## Alternatives considered
**Use `command/run` as the feedback record.** Rejected because feedback would then be coupled to one trigger and consumers would have to identify a domain fact by command name. A non-command producer could not create the same record without pretending to execute a command.
**Store the text in both `feedback/record` and `command/run.args`.** Rejected because one act would have two payload copies with no useful distinction. `recordInput: false` preserves the generic lifecycle while leaving the domain event authoritative.
**Inject feedback as a user message via `agent.inject()`.** Needs no new event type and reuses the path `/goal` mutations take. Rejected: it makes the feedback model-visible, so it enters the next request, changes the run being commented on, and consumes tokens — contradicting all three parts of the no-perturbation requirement.
**Make `/feedback` a true no-op that records nothing.** The most literal reading of "does not do anything". Rejected because it makes the command pointless: the stated requirement was that the remark reach the session log.
**Register the command inside an existing package** such as `packages/ui/commands`. Avoids a new group and its README pair. Rejected: `ctx.commands` is the registry, not a home for arbitrary command implementations, and the requester asked for a standalone package.
**Parse structure out of the text** (category prefixes, severity markers). Rejected as speculative: no consumer needs that structure, and any control-word grammar makes the corresponding literal feedback unrecordable. Verbatim text is the widest surface a future consumer can narrow; a parsed one cannot be widened after the fact.
**Add a model-facing tool instead of a slash command.** Rejected: feedback is a direct human observation. Routing it through the model spends a turn, lets the model paraphrase the user's words, and makes the record contingent on the model choosing to call the tool.
## Consequences
The shipped `dsh` base mounts the command unconditionally — no configuration, no dependency on the goal stack. The Web client exposes it through its command adapter. Headless mode, ACP, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there.
The package owns one independent append-only event with no cross-event or mutable-data relation for an invariant companion to check. The event follows the session log's existing replay, fork, persistence, and crash-tail behavior.
Deferred: no product or model consumer; no structured fields; no amend or withdraw, since the log is append-only and this package adds no tombstone; and no explicit durability barrier, so an entry recorded immediately before a crash can be lost with any other unflushed tail. The optional telemetry consumer treats the event only as an export-policy trigger.
No keyless transcript snapshot accompanies this change, at the requester's explicit direction. Package tests, a real Loader composition test over a `cordis.yml`, and the shipped Web composition test cover registration, capture, model exclusion, and product assembly.

View File

@@ -0,0 +1,63 @@
# Agent Note: `/feedback` 命令
Status: implemented
[English](2026-07-28-feedback-command.md) | 中文
## 问题
用户在会话中途发现问题时,没有地方记下这个观察。告诉模型会浪费一个轮次、改变用户原本进行的对话,并把这条评论埋进派生历史,使后续读者无法找到它。写到会话之外则会丢失让它有意义的上下文:属于哪个会话、处于哪个时点、针对哪项工作。
采集接口必须能在用户产生不满的那一刻使用,因此任何需要用户离开交互式客户端的方案都不可行;它还不能扰动正在进行的运行:不消耗模型 token、不产生工作轮次、不改变用户正在等待的请求。
## 决策
位于 `packages/feedback/command-feedback/``@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback <text>` 给出确认;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。
本包package声明仅写入日志的 `feedback/record { text }` 会话事件,并导出 `recordFeedback(session, text)`,作为不依赖命令的生产方。该生产方丢弃前后空白,拒绝空结果,并且恰好追加一个事件。`/feedback` 委托给它,因此其他 UI、钩子或 host 集成无需构造斜杠命令也能记录同一个领域事实。
`dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会启动持久化的常规即时排空没有任何环节强制 flush因此确认文本报告的是反馈已进入日志而非已经落盘。
采集对正在运行的 agent 与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为本地警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。
### 为何反馈拥有自己的事件
反馈是领域事实,而 `/feedback` 是一种触发方式。只把载荷保存在 `feedback/record` 中,既让后续触发方式可以使用同一个事件,也让消费方无需依赖命令名或解析命令生命周期记录即可筛选反馈。在该定义中省略 `command/run.args`,可避免同一条人类评价出现两个看起来都具有权威性的副本。
### 为何模型永不看到它
反馈是关于会话的,而不是会话的输入。将其作为 user 消息注入会改变下一次模型请求,与「记录不得扰动运行」的要求相冲突,也会让该评论成为它所评论的那段对话的一部分。`command/run``command/done` 不属于 `SurfaceEventType`,因此即便出错也无法获得 `surfaceOp` 或进入派生历史。
### 原样文本
前后空白会被丢弃,但除此之外不做解析。`/feedback /plan felt slow` 记录 `/plan felt slow`;开头的 `/plan` 是内容,而非嵌套命令。若采用 `/goal` 那样的控制词语法,对应的字面反馈将无法表达,这与采集接口的目的正好相反。
### 一个新的分组
`packages/feedback/` 是新分组,因为现有分组都不拥有此职责:`goal/` 负责目标状态,`session-title/` 负责标题,`core/` 是产品主干。该分组只包含一个生产方包;跨领域的消费方留在各自所属的分组,而不是迫使这个包不断膨胀。
## 考虑过的替代方案
**使用 `command/run` 作为反馈记录。** 已否决,因为这会将反馈与一种触发方式耦合,消费方还必须通过命令名识别领域事实。非命令生产方若不伪装成执行命令,就无法创建相同记录。
**同时在 `feedback/record` 与 `command/run.args` 中存储文本。** 已否决,因为同一行为会产生两个没有实质区别的载荷副本。`recordInput: false` 保留通用生命周期,同时让领域事件保持权威性。
**通过 `agent.inject()` 将反馈作为 user 消息注入。** 无需新增事件类型,并复用 `/goal` 变更所走的路径。已否决:它会让反馈对模型可见,从而进入下一次请求、改变正被评论的那次运行并消耗 token——与「不得扰动」要求的三个方面全部冲突。
**让 `/feedback` 成为真正的空操作,什么都不记录。** 这是对「什么都不做」最字面的理解。已否决:这会使命令失去意义——明确的要求是让这条评论进入会话日志。
**在现有包中注册该命令**,例如 `packages/ui/commands`。可省去新分组及其双语 README。已否决`ctx.commands` 是注册表,而不是任意命令实现的归属地;且请求者明确要求独立的包。
**从文本中解析结构**(类别前缀、严重程度标记)。已否决,属于投机设计:没有消费方需要该结构,而任何控制词语法都会让对应的字面反馈无法记录。原样文本是未来消费方可以收窄的最宽接口;而已被解析的接口无法事后放宽。
**改为提供面向模型的工具。** 已否决:反馈是人类的直接观察。经由模型会消耗一个轮次、让模型改写用户的原话,并使记录取决于模型是否选择调用该工具。
## 后果
随附的 `dsh` 基础组合无条件挂载该命令:没有配置,也不依赖 goal 栈。Web 客户端通过命令适配器暴露该命令。无头模式、ACP 和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。
本包拥有一个独立的仅追加事件不存在跨事件关系或可变数据关系可供不变式伴生插件检查。该事件遵循会话日志现有的回放、fork、持久化和崩溃尾部行为。
延期事项:没有产品或模型消费方;没有结构化字段;不支持修改或撤回,因为日志仅追加且本包不新增 tombstone且没有显式持久化屏障因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。可选的遥测消费方只将该事件作为导出策略触发器。
本次变更按请求者的明确指示不附带无密钥 transcript文本记录快照。包测试、基于真实 `cordis.yml` 的 Loader 组合测试,以及随附的 Web 组合测试覆盖注册、采集、模型排除和产品组装。

View File

@@ -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-08-05-feedback-gated-session-telemetry.md
2026-08-05-feedback-gated-session-telemetry.md: 7d923a7e4cf61e8d1119187564b87e4cbb2065b7
2026-08-05-feedback-gated-session-telemetry.zh.md: 2862162c0c36e5194846c7e1c7bbc24230ce90aa

View File

@@ -0,0 +1,35 @@
# Agent Note: Feedback-gated session telemetry
Status: implemented
English | [中文](2026-08-05-feedback-gated-session-telemetry.zh.md)
## Problem
Session telemetry originally has one mounted behavior: every accepted record enters the reporting backend immediately. Deployments need two stricter policies without replacing the plugin: hold a session's telemetry unless its user records feedback, or disable reporting while still explaining what happens to feedback. The policy must preserve the existing full-export default and the telemetry seam's redaction-before-backend boundary.
## Decision
`@deepseek-ai/dsh-session-telemetry-otel` exposes the string-valued `TelemetryMode` enum to TypeScript callers and accepts the same three uppercase `mode` values in serialized configuration:
- `FULL` is the default and preserves immediate delivery to the configured OTel pipeline.
- `FEEDBACK_ONLY` reads the canonical session log when `feedback/record` is appended and hands over the unreleased prefix through that exact event. Records appended after that boundary remain local until another feedback event.
- `DISABLED` constructs no exporter, processor, or logger provider. A `feedback/record` listener prints that nothing is shared and the feedback remains local.
The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records.
Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when the exact `feedback/record` object is already stored at `session.events[event.seq]`. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration.
## Alternatives considered
**Open a session permanently after its first feedback.** Rejected because later work would be shared without another feedback act and the plugin would need additional open-session state. Releasing one pending prefix per feedback has the smaller state machine and the narrower sharing boundary.
**Retain capture-time redacted records until feedback.** Rejected because it duplicates an unbounded session prefix even though the canonical log already owns the events. It preserves capture-time redaction policy and operational records, but those properties do not justify the memory cost for a mode defined as uploading the session log after feedback.
**Temporarily allow public `emit()` calls during feedback replay.** Rejected because a redaction listener or another reentrant caller could enqueue an unrelated record while the flag was open. A private backend capability makes authorization structural and keeps the public service closed throughout replay.
**Use an unmounted plugin as the disabled state.** That remains the silent opt-out, but it cannot warn when feedback is recorded. The explicit disabled mode lets a deployment keep one configuration shape and communicate that the local feedback did not leave the process.
## Consequences
`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; direct service calls and non-canonical feedback events upload nothing, and a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log.

View File

@@ -0,0 +1,35 @@
# Agent Note反馈门控的会话遥测
Status: implemented
[English](2026-08-05-feedback-gated-session-telemetry.md) | 中文
## 问题
会话遥测原本只有一种已挂载行为:每条已接受记录都立即进入上报后端。部署方需要两种更严格的策略,且不替换插件:只有用户记录反馈时才释放该会话的遥测,或禁用上报并仍向用户说明反馈的去向。该策略必须保留现有的全量导出默认值,以及遥测 seam 在记录抵达后端之前脱敏的边界。
## 决策
`@deepseek-ai/dsh-session-telemetry-otel` 向 TypeScript 调用方公开以字符串为值的 `TelemetryMode` 枚举,并在序列化配置中接受相同的三个大写 `mode` 值:
- `FULL` 是默认值,保留向已配置 OTel 流水线的即时投递。
- `FEEDBACK_ONLY` 在追加 `feedback/record` 时读取权威会话日志,并交接截至该事件的未释放前缀。该边界后追加的记录会留在本地,直到另一个反馈事件。
- `DISABLED` 不构造导出器、处理器或日志提供方。`feedback/record` 监听器会输出警告,说明什么都不会共享,且反馈仍留在本地。
通用遥测协调器拥有 `live``on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)说明了按需路径为何使用权威日志而非记录副本。
模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是该事件与那个 `feedback/record` 对象身份完全相同,且该对象已存储于 `session.events[event.seq]``Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。
## 考虑过的替代方案
**会话在首次反馈后永久开放。** 已否决,因为后续工作会在用户未再次提交反馈的情况下被共享,而且插件需要额外的会话开放状态。每次反馈只释放一个待处理前缀,状态机更小,共享边界也更窄。
**反馈前保留捕获时已脱敏记录。** 已否决,因为权威日志已拥有这些事件,该方案仍会复制无上限的会话前缀。它能保留捕获时的脱敏策略与运维记录,但对于一个定义为「反馈后上传会话日志」的模式,这些性质不足以证明该内存成本合理。
**在反馈回放期间临时允许公开 `emit()` 调用。** 已否决,因为在标志开启期间,脱敏监听器或另一个可重入调用方可能将无关记录入队。私有后端能力使授权成为结构性保证,并确保公共服务在整个回放过程中保持关闭。
**以不挂载插件表示禁用状态。** 这仍然是静默退出方式,但无法在记录反馈时输出警告。显式禁用模式让部署方可以保持同一种配置形态,并说明本地反馈未离开进程。
## 后果
`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;直接服务调用与非权威反馈事件均不上传任何内容,且反馈前发生崩溃时,该前缀也不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。

View File

@@ -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-08-06-web-skill-tool-row.md
2026-08-06-web-skill-tool-row.md: 6583062f38b0e9cff059fa4477313ff6a5bdd2aa
2026-08-06-web-skill-tool-row.zh.md: 3d5c4b712896c2cf41df3ec913c597f7f791486c

View File

@@ -0,0 +1,29 @@
# Agent Note: Web skill tool row
Status: implemented
English | [中文](2026-08-06-web-skill-tool-row.zh.md)
## Problem
The Web transcript renders `skill` calls through the generic fallback row, so a loaded instruction set looks like an unknown tool call even though Skill is a first-class product concept. The generic row also exposes the JSON argument envelope beside the result, adding noise around the one identity users need: the loaded skill name.
## Decision
`ui-skill` registers a component under the existing `conversation.chat.toolview` keyed slot with key `skill`. The component owns its row chrome from the public `ToolRowProps` contract, matching the independent registrant posture used by the Bash sample instead of importing conversation-private components.
The collapsed row uses a 16-pixel document-and-sparkle glyph and the Bash row's neutral hierarchy: tertiary glyph, secondary `Skill` title, caption separator, and tertiary skill name. Running, failed, and interrupted calls retain the transcript's shimmer, error dot and first-line summary, and warning dot semantics. A settled call expands through the whole summary row into a 260-pixel bounded `Instructions` card containing the exact durable result text; the existing trajectory `Inspect` handoff remains available below the card.
The row derives every visible value from a paired call/result slice in the current runtime window. It reads the skill name from the recorded `name` argument and the instructions from durable result content, and never joins the current skill catalog for descriptions or provider metadata. If pagination leaves the call outside the window, the result has no tool identity and remains on the generic fallback rather than extending the history wire contract. The existing ACP `skill-load` recording is seeded through the real Web persistence and composition path for a keyless interaction and accessibility snapshot.
## Alternatives considered
- Keep the generic tool row and add only a `skill` color selector in `ui-conversation`. This leaves the redundant input envelope and generic expanded body in place, and makes the conversation package own a domain-specific visual rule.
- Add a new `skill` value to the host tool render-intent union. The keyed client slot already identifies this tool when its call is in the runtime window, so a new cross-boundary presentation value adds protocol and snapshot surface without enabling another consumer.
- Export the conversation package's private `ToolRow` component for reuse. Client packages intentionally expose contracts rather than cross-package components; exporting it would couple independent feature packages to conversation implementation details.
## Consequences
`ui-skill` now depends on the public conversation toolview contract, locale and primitive packages, and React in addition to its reference-source dependencies. It owns a small copy of the disclosure-row chrome, so future global interaction changes must update this registrant alongside the Bash sample and conversation rows.
Cold replay stays deterministic when the installed skill catalog changes, and the transcript remains compact until instructions are explicitly expanded. A result-only history page intentionally uses the generic fallback; keeping this edge case generic preserves the existing history protocol and confines the feature to client presentation. The dedicated card intentionally shows the tool's complete framed output rather than extracting only `<skill_instructions>`, preserving exactly what reached the model and avoiding a second parser for the skill result format.

View File

@@ -0,0 +1,29 @@
# Agent Note: Web skill 工具行
Status: implemented
[English](2026-08-06-web-skill-tool-row.md) | 中文
## 问题
Web transcript文本记录通过通用后备行渲染 `skill` 调用,使已加载的指令集看起来像一次未知工具调用,尽管 Skill技能已是产品中的一等概念。通用行还会在结果旁暴露 JSON 参数的外层结构,围绕用户真正需要的唯一标识增加了噪声:已加载的 skill 名称。
## 决策
`ui-skill` 在现有的 `conversation.chat.toolview` 键控 slot 下注册 key 为 `skill` 的组件。该组件基于公开的 `ToolRowProps` 契约自行实现行 chrome沿用 Bash 示例的独立注册方姿态,而不导入 conversation 私有组件。
收起的行使用 16 像素的文档与闪光组合图标,并沿用 Bash 行的中性色层级:图标采用三级色,`Skill` 标题采用二级色,分隔符采用 caption 色skill 名称采用三级色。运行、失败和中断调用分别沿用 transcript 的扫光、错误状态点加首行摘要,以及警告状态点语义。已结算调用可以通过整个摘要行展开一个高度上限为 260 像素的 `Instructions` 卡片,其中原样呈现持久化结果文本;用于跳转至 trajectory 的现有 `Inspect` 入口仍保留在卡片下方。
该行的所有可见值均派生自当前 runtime 窗口中已配对的调用结果片段。skill 名称来自已记录的 `name` 参数,指令来自持久化的结果内容;该行绝不关联当前 skill 目录来读取描述或提供方元数据。如果分页将调用留在窗口外,结果便没有工具身份,并继续使用通用后备路径,而不是扩展 history 协议契约。现有的 ACPAgent Client Protocol`skill-load` 记录经由真实的 Web 持久化与组合路径写入,用于无需密钥的交互和无障碍快照。
## 考虑过的替代方案
- 保留通用工具行,只添加一个 `skill` 颜色选择器,并将其放在 `ui-conversation` 中。该方案仍会保留多余的输入外层结构和通用展开体,也会让 conversation 包拥有特定领域的视觉规则。
- 在宿主工具渲染意图联合类型中添加新的 `skill` 值。键控客户端 slot 在调用位于 runtime 窗口内时已经能够识别该工具,因此新的跨边界呈现值只会增加协议和快照表层,却不会支持其他消费方。
- 导出 conversation 包的私有 `ToolRow` 组件供复用。客户端包刻意对外暴露契约而非跨包组件;导出该组件会使独立功能包耦合到 conversation 的实现细节。
## 后果
除了引用 source 的依赖外,`ui-skill` 现在还依赖公开的 conversation toolview 契约、locale 包、原语包和 React。它自行保留了一小份折叠展开行 chrome因此未来的全局交互变更必须与 Bash 示例和 conversation 行同步更新这个注册方。
即使已安装的 skill 目录发生变化冷回放仍保持确定性在用户显式展开指令前transcript 保持紧凑。仅含结果的 history 页有意使用通用后备路径;让这个边缘情况保持通用呈现,可以保留现有 history 协议,并将该功能限定在客户端呈现层。专用卡片有意显示工具完整封装的输出,而不是只提取 `<skill_instructions>`,从而原样保留模型实际收到的内容,也避免为 skill 结果格式再引入一个解析器。

View File

@@ -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/simplification/2026-08-06-buffer-free-feedback-telemetry.md
2026-08-06-buffer-free-feedback-telemetry.md: 008bebdcb59f7ef4fe49f8e731aad77861368d5c
2026-08-06-buffer-free-feedback-telemetry.zh.md: 7052e075921f4470864f5ea1c4aed5cf6201becf

View File

@@ -0,0 +1,29 @@
# Agent Note: Buffer-free feedback telemetry
Status: implemented
English | [中文](2026-08-06-buffer-free-feedback-telemetry.zh.md)
## Problem
Feedback-only telemetry must upload the session-log prefix only after recorded feedback. Retaining a deep-copied, redacted record for every projected event until that trigger duplicates the canonical session log and grows without a bound for a long-lived session that never records feedback.
## Decision
The telemetry coordinator provides `live` and `on-demand` capture. On-demand capture registers no session, flush, or operational-event listeners and retains no projected records. `captureSession(session, throughSeq?)` reads the canonical session log after the handoff cursor through an optional inclusive sequence boundary, applies the fixed projection, deep-copies each accepted event, runs the current `telemetry/record` waterfall, and hands the result to the backend.
`FEEDBACK_ONLY` invokes that method with the `feedback/record` event's sequence. The append is already committed when `session/event` listeners run, so the replay contains the feedback event and cannot include a later suffix. The existing handoff cursor distinguishes later replays without another pending-record index.
Because on-demand capture reads only the canonical log, it emits no `agent-error` or `shutdown` operational records. Redaction is evaluated at feedback time rather than append time. The [feedback mode decision](../feature/2026-08-05-feedback-gated-session-telemetry.md) owns the public sharing behavior; this note owns its buffer-free realization.
## Alternatives considered
**Retain capture-time redacted records.** This preserves the exact redaction policy and operational records observed when each event occurs, but duplicates the unbounded session prefix. The mode promises feedback-triggered session-log upload, not capture-time policy snapshots or pre-feedback operational telemetry.
**Retain session event references or sequence numbers.** Rejected because the canonical log already supplies both order and identity. A second index saves payload copies but adds lifecycle state without enabling any required behavior.
**Write a durable pre-feedback spool.** Deferred until a deployment requires crash recovery before feedback. It adds storage, cleanup, and confidentiality policy to a mode whose intended behavior is to upload nothing when the process exits before feedback.
## Consequences
A no-feedback session consumes no telemetry-owned memory proportional to its event count; the canonical session log remains the only pre-feedback copy. Feedback handling performs projection, cloning, and redaction synchronously before the backend's non-blocking enqueue, so its cost scales with the unreleased prefix. A redaction-policy change before feedback affects that replay, and a crash before feedback uploads nothing. Later feedback processes only events beyond the handoff cursor.

View File

@@ -0,0 +1,29 @@
# Agent Note: 无缓冲反馈遥测
Status: implemented
[English](2026-08-06-buffer-free-feedback-telemetry.md) | 中文
## 问题
仅反馈遥测必须只在记录反馈后上传会话日志前缀。若在触发前为每个已投影事件保留一份已深拷贝、已脱敏的记录,就会复制权威会话日志;对于长期运行但从不记录反馈的会话,这份副本会无限增长。
## 决策
遥测协调器提供 `live``on-demand` 捕获。按需捕获不注册会话、flush 或运维事件监听器,也不保留投影记录。`captureSession(session, throughSeq?)` 从 handoff 游标之后读取权威会话日志,直至可选的序列号边界(含边界),应用固定投影、深拷贝每个已接受事件、运行当前的 `telemetry/record` waterfall瀑布式事件并将结果交给后端。
`FEEDBACK_ONLY``feedback/record` 事件的序列号调用该方法。`session/event` 监听器运行时,追加已经提交,因此回放包含该反馈事件,且无法包含后续后缀。现有 handoff 游标可区分后续回放,无需另一个待处理记录索引。
按需捕获只读取权威日志,因此不会发出 `agent-error``shutdown` 运维记录。脱敏在反馈时而非追加时求值。[反馈模式决策](../feature/2026-08-05-feedback-gated-session-telemetry.md)规定公开的共享行为;本记录规定其无缓冲实现。
## 考虑过的替代方案
**保留捕获时的已脱敏记录。** 该方案会保留每个事件发生时观察到的确切脱敏策略与运维记录,但也会复制无上限的会话前缀。该模式承诺在反馈触发后上传会话日志,而非保留捕获时策略快照或反馈前运维遥测。
**保留会话事件引用或序列号。** 已否决,因为权威日志已同时提供顺序与身份。第二个索引可以省去载荷副本,但会增加生命周期状态,且无法实现任何必需行为。
**写入持久化的反馈前 spool。** 推迟到有部署要求反馈前的崩溃恢复时再实现。该方案会为一个预期在进程于反馈前退出时不上传任何内容的模式增加存储、清理与保密策略。
## 后果
没有反馈的会话不会消耗随事件数量增长的遥测自有内存;权威会话日志仍是反馈前的唯一副本。反馈处理会在后端非阻塞入队前同步执行投影、深拷贝与脱敏,因此其开销随未释放前缀增长。反馈前的脱敏策略变更会影响该次回放,而反馈前发生崩溃时什么都不上传。后续反馈只处理 handoff 游标之后的事件。

View File

@@ -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/proposed/architecture/2026-07-27-session-projection-and-command-log.md
2026-07-27-session-projection-and-command-log.md: 789e79f2ecab1a9f3ac717df86059150ed2d4da9
2026-07-27-session-projection-and-command-log.zh.md: 4d680b37f5d49a243447542706c8b7ced8d80e2a
2026-07-27-session-projection-and-command-log.md: 8226325d4cd4047c3a9c41eb9427082c59753dd5
2026-07-27-session-projection-and-command-log.zh.md: b55704f28799008b5e2323d846696e4768fcd6d6

View File

@@ -119,11 +119,11 @@ The one existing violation of "no hooks through inject" — `DetailsInjected.use
Two log-only (non-surface, model-invisible) events, mirroring the `tool/call`/`tool/result` pairing:
```ts ignore-check
'command/run': { commandId: string; name: string; args: string; source: CommandSource }
'command/run': { commandId: string; name: string; args?: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
```
The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged.
The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the [synthetic-turn removal](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md): no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — `name` and, by default, `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. A definition sets `recordInput: false` when its authoritative domain event owns the payload; `command/run` then omits `args` rather than duplicating it. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged.
Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to admission — `{ matched, commandId? }`: whether the line resolved, and the minted pairing id when it did, so the issuing client can correlate its request with the flow node the lifecycle events produce. The one-shot notice channel (`runDetached` → `noticeFor`) is retired.

View File

@@ -58,7 +58,7 @@ declare module 'cordis' {
### 已交付的消费方subagent 身份单元
注册表的两处读面已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md)注册了 `subagent` 单元——从 `subagent/descriptor` last-wins 折叠出的持久 mode/label 身份——`SubagentService.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 一次持久化检查上的 `restore({}, events, 0)` 读取。注册表契约不变:没有失败通道、没有新读——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。
注册表的两处既有读法已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md)注册了 `subagent` 单元——从 `subagent/descriptor` last-wins 折叠出的持久 mode/label 身份——`SubagentService.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 则用一次持久化整读的结果调用 `restore({}, events, 0)` 读取。注册表契约不变:没有失败通道、没有新读——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。
### 协议层:历史尾页上的 projections 块
@@ -119,11 +119,11 @@ type UseProjection = {
两个仅日志(非 surface、模型不可见事件镜像 `tool/call`/`tool/result` 的配对:
```ts ignore-check
'command/run': { commandId: string; name: string; args: string; source: CommandSource }
'command/run': { commandId: string; name: string; args?: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
```
host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent 的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)之后所有插件自有 log-only 事件同一形状没有轮次包裹它们轮次只描述模型循环执行持久化在常规检查点排空它们run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` `args` 就是解析器自己的切分(`parseCommand` 的 name 与 rawInput因此消费方折叠自己命令记录的投影单元、富命令卡片永远无需重新解析行文本。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据不是呈现版式如何编排仍由客户端在渲染时计算满足「呈现永不入日志」这条红线。想让模型知道结果的领域继续做它们今天在做的事plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。
host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`——在接收 agent 的会话上直接独立追加,与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)之后所有插件自有 log-only 事件同一形状没有轮次包裹它们轮次只描述模型循环执行持久化在常规检查点排空它们run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 以及默认携带的 `args` 来自解析器自己的切分(`parseCommand` 的 name 与 rawInput因此消费方折叠自己命令记录的投影单元、富命令卡片永远无需重新解析行文本。当载荷由权威领域事件持有时,命令定义会设置 `recordInput: false`;此时 `command/run` 省略 `args`,而不是重复该载荷。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据不是呈现版式如何编排仍由客户端在渲染时计算满足「呈现永不入日志」这条红线。想让模型知道结果的领域继续做它们今天在做的事plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。
由于已提交事件会在 mux 流上广播刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为准入判定——`{ matched, commandId? }`:该行是否匹配命中,以及命中时新铸的配对 id发起命令的客户端据此把自己的请求与生命周期事件产出的 flow 节点关联起来。一次性通知通道(`runDetached` → `noticeFor`)就此下线。

View File

@@ -78,6 +78,8 @@ flowchart LR
cfg --> plugin_dsh_base_tool_skill
plugin_dsh_base_commands["commands<br/>@deepseek-ai/dsh-commands"]
cfg --> plugin_dsh_base_commands
plugin_dsh_base_command_feedback["command-feedback<br/>@deepseek-ai/dsh-command-feedback"]
cfg --> plugin_dsh_base_command_feedback
plugin_dsh_base_goal["goal<br/>@deepseek-ai/dsh-goal"]
cfg --> plugin_dsh_base_goal
plugin_dsh_base_goal_session["goal-session<br/>@deepseek-ai/dsh-goal-session"]
@@ -187,6 +189,7 @@ flowchart LR
| `skill-local` | `@deepseek-ai/dsh-skill-local` |
| `tool-skill` | `@deepseek-ai/dsh-tool-skill` |
| `commands` | `@deepseek-ai/dsh-commands` |
| `command-feedback` | `@deepseek-ai/dsh-command-feedback` |
| `goal` | `@deepseek-ai/dsh-goal` |
| `goal-session` | `@deepseek-ai/dsh-goal-session` |
| `command-goal` | `@deepseek-ai/dsh-command-goal` |

View File

@@ -6,13 +6,14 @@
import { tmpdir } from 'node:os'
import { afterEach, expect, it } from 'vitest'
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
import { SessionId } from '@deepseek-ai/dsh-session'
// Empty type imports carry the tools/sandboxPolicy/approval Context merges.
import type {} from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-permission'
import { SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent-presets'
import type {} from '@deepseek-ai/dsh-commands'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
/**
@@ -96,4 +97,19 @@ it('assembles the shipped Web catalog with the confined access default', async (
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
expect(scaffold.ctx.approval.config.policy).toBe('ask')
expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
const commandHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('shipped-command-catalog'),
meta: { cwd: scaffold.workspaceCwd },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
try {
expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({
name: 'feedback',
description: 'record feedback about this session',
input: { hint: '<text>' },
})
} finally {
await commandHandle.dispose()
}
}, 120_000)

View File

@@ -0,0 +1,80 @@
// Web e2e scenario: the real skill-load recording, seeded cold through the
// persistence seam, renders through ui-skill's keyed toolview without a model
// call. The disclosure proves replay-stable naming and exact durable output.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/skill-load/session.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-tool-row', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/skill-tool-row/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'skill-tool-row-web-e2e'
const PROMPT = 'Load the snapshot-skill skill with the skill tool, then reply DONE.'
describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
const fixture = await readFile(FIXTURE, 'utf8')
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
scaffold = await launchWebScaffold({})
await seedSession(scaffold, fixture, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('expands the loaded skill to its exact recorded instructions', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-tool-row'))
const call = page.locator('[data-tool="skill"]')
const row = call.getByRole('button', { name: 'Skill snapshot-skill' })
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
expect(await call.getByText('snapshot-skill', { exact: true }).count()).toBe(1)
await row.click()
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
await call.getByText('Instructions', { exact: true }).waitFor()
const output = call.locator('pre')
await output.waitFor()
expect(await output.textContent()).toContain('<skill_content name="snapshot-skill">')
expect(await output.textContent()).toContain('Follow these snapshot-only instructions.')
expect(await output.evaluate(element => getComputedStyle(element.parentElement!).maxHeight)).toBe('260px')
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -1,6 +1,7 @@
- listbox "Trigger suggestions":
- text: Commands
- option "compact Compact older conversation history" [selected]
- option "feedback record feedback about this session"
- option "goal set or view the goal for a long-running task"
- option "permission Switch the permission preset (sandbox mode + approval policy)"
- option "plan Enter or leave plan mode"

View File

@@ -0,0 +1,45 @@
- banner:
- navigation "Session hierarchy":
- button "Load the snapshot-skill skill with" [disabled]
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: Load the snapshot-skill skill with the skill tool, then reply DONE. {{date}} {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Context injection skill-catalog":
- img
- img
- text: Context injection skill-catalog
- button "Think Load the requested skill.":
- img
- img
- text: Think Load the requested skill.
- button "Skill snapshot-skill" [expanded]:
- img
- text: Skill snapshot-skill
- region "Instructions": "Instructions <skill_content name=\"snapshot-skill\"> <skill_resources> Base directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. </skill_resources> <skill_instructions> Follow these snapshot-only instructions. Resolve referenced resources relative to this skill directory. </skill_instructions> </skill_content>"
- button "Inspect"
- button "Think The skill is loaded.":
- img
- img
- text: Think The skill is loaded.
- paragraph: DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model":
- text: Select model
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok

View File

@@ -63,6 +63,7 @@
"tests/startup-auto-selection.e2e.ts",
"tests/subagent-conversation.e2e.ts",
"tests/bash-abort-row.e2e.ts",
"tests/skill-tool-row.e2e.ts",
"tests/turn-tail-actions.e2e.ts",
"tests/goal-multi-turn-actions.e2e.ts",
"tests/chat-scroll-fixture.ts",

View File

@@ -1399,11 +1399,13 @@ Requires: `sessions`
```ts config-catalog
/**
* Plugin configuration: two verbatim SDK option shapes plus one DSH-owned
* shutdown bound. The package validates its endpoint and shutdown deadline
* because both must fail at plugin load rather than at first export or exit.
* Plugin configuration: one sharing policy, two verbatim SDK option shapes,
* and one DSH-owned shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Sharing policy; defaults to immediate `FULL` delivery. */
mode?: TelemetryMode
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
* `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
@@ -1411,7 +1413,7 @@ export interface Config {
* is the one field this package requires and validates itself.
*/
exporter?: OTLPExporterNodeConfigBase & {
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
url?: string
}
/**
@@ -1422,11 +1424,18 @@ export interface Config {
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
shutdownTimeoutMillis?: number
}
/** Session-sharing policy selected by {@link Config.mode}. */
export enum TelemetryMode {
FULL = 'FULL',
FEEDBACK_ONLY = 'FEEDBACK_ONLY',
DISABLED = 'DISABLED',
}
```
Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:41`](../packages/telemetry/session-telemetry-otel/src/index.ts)
Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:80`](../packages/telemetry/session-telemetry-otel/src/index.ts)
## `@deepseek-ai/dsh-session-title`
@@ -2572,6 +2581,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
- `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts))
- `@deepseek-ai/dsh-command-compact` — requires `commands` · `compact` ([`packages/compact/command-compact/src/index.ts`](../packages/compact/command-compact/src/index.ts))
- `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts))
- `@deepseek-ai/dsh-command-goal` — requires `commands` · `goals` ([`packages/goal/command-goal/src/index.ts`](../packages/goal/command-goal/src/index.ts))
- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts))
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))

View File

@@ -343,7 +343,7 @@ A command was registered or unregistered. This is an unfiltered registry notific
'commands/change'(): void
```
Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts)
Source: [`packages/ui/commands/src/index.ts:161`](../../packages/ui/commands/src/index.ts)
## `credentials/*`
@@ -798,7 +798,7 @@ Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/syst
### `telemetry/record` — waterfall
Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten.
Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Live capture dispatches at append time; on-demand capture dispatches while reading the canonical log. Redaction applies to the exported copy only; the canonical session log is never rewritten.
```ts cordis-catalog
/**
@@ -812,8 +812,9 @@ Transform one outbound record before it reaches the backend. This waterfall is t
* `next()` replaces everything beneath. Dispatched synchronously on the
* capture hot path inside the coordinator's containment: a throwing
* listener withholds that one record (fail-closed) and never reaches the
* agent loop. Redaction applies to the exported copy only; the canonical
* session log is never rewritten.
* agent loop. Live capture dispatches at append time; on-demand capture
* dispatches while reading the canonical log. Redaction applies to the
* exported copy only; the canonical session log is never rewritten.
* @param record - the candidate record, already the coordinator's own deep
* copy; listeners return a (possibly new) record and must not mutate it.
* @mode waterfall
@@ -821,7 +822,7 @@ Transform one outbound record before it reaches the backend. This waterfall is t
'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord
```
Source: [`packages/telemetry/session-telemetry/src/index.ts:41`](../../packages/telemetry/session-telemetry/src/index.ts)
Source: [`packages/telemetry/session-telemetry/src/index.ts:43`](../../packages/telemetry/session-telemetry/src/index.ts)
## `tools/*`

View File

@@ -524,7 +524,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise<Comma
Types: [Agent](../core-data-structures/core.md) · [CommandDefinition](../core-data-structures/commands.md) · [CommandDescriptor](../core-data-structures/commands.md)
Source: [`packages/ui/commands/src/index.ts:278`](../../packages/ui/commands/src/index.ts)
Source: [`packages/ui/commands/src/index.ts:286`](../../packages/ui/commands/src/index.ts)
## `ctx.compact` — `CompactService` (abstract seam)
@@ -2437,7 +2437,7 @@ flush?(): void
abstract shutdown(): Promise<void>
```
Source: [`packages/telemetry/session-telemetry/src/index.ts:135`](../../packages/telemetry/session-telemetry/src/index.ts)
Source: [`packages/telemetry/session-telemetry/src/index.ts:140`](../../packages/telemetry/session-telemetry/src/index.ts)
## `ctx.tokenMeter` — `TokenMeterService`

View File

@@ -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/commands.md
commands.md: 056c775f4c2e1586447db11821e5c7d56be01881
commands.zh.md: 6339b3e87c04eac0fd140a7cab57b2ad18bbf5c2
commands.md: bc7cf676232a4271d7bc21d6688bc598d9325957
commands.zh.md: a2f82c06ac7ca87c5d5cfa525aaf9afa8777e9c9

View File

@@ -31,6 +31,12 @@ interface CommandDefinition {
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
/**
* Whether `command/run` records `rawInput`. Defaults to true. A command
* whose domain event owns the payload sets this false to avoid duplicating
* that payload in the session log.
*/
readonly recordInput?: boolean
/** Execute against the receiving agent without sending the command to the model. */
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}

View File

@@ -31,6 +31,12 @@ interface CommandDefinition {
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: CommandInputDescriptor
/**
* Whether `command/run` records `rawInput`. Defaults to true. A command
* whose domain event owns the payload sets this false to avoid duplicating
* that payload in the session log.
*/
readonly recordInput?: boolean
/** Execute against the receiving agent without sending the command to the model. */
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}

View File

@@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../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), `apiproxy` |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:161`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `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) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
@@ -32,7 +32,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../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:73`](../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), [`permission`](../packages/ui/permission), [`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:83`](../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-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `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), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `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), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:104`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
@@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:43`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |

View File

@@ -203,6 +203,9 @@ flowchart TD
pkg_cli_demo["cli-demo"]
pkg_jsonrpc_demo["jsonrpc-demo"]
end
subgraph group_feedback["packages/feedback"]
pkg_command_feedback["command-feedback"]
end
subgraph group_guard["packages/guard"]
pkg_repeat_tool_guard["repeat-tool-guard"]
end
@@ -463,11 +466,6 @@ flowchart TD
pkg_client_ui_layout --> pkg_client_ui_slots
pkg_client_ui_layout --> pkg_client_ui_theme
pkg_client_ui_layout --> pkg_invariants
pkg_client_ui_skill --> pkg_client_connection
pkg_client_ui_skill --> pkg_client_runtime
pkg_client_ui_skill --> pkg_client_ui_slash
pkg_client_ui_skill --> pkg_client_ui_slots
pkg_client_ui_skill --> pkg_invariants
pkg_code_runtime_worker --> pkg_code_runtime
pkg_code_runtime_worker --> pkg_invariants
pkg_code_runtime_worker --> pkg_session
@@ -690,6 +688,9 @@ flowchart TD
pkg_client_ui_conversation --> pkg_client_ui_slots
pkg_client_ui_conversation --> pkg_invariants
pkg_client_ui_conversation --> pkg_token_meter
pkg_command_feedback --> pkg_commands
pkg_command_feedback --> pkg_invariants
pkg_command_feedback --> pkg_session
pkg_pty_local --> pkg_agent
pkg_pty_local --> pkg_invariants
pkg_pty_local --> pkg_pty
@@ -701,12 +702,6 @@ flowchart TD
pkg_tasks_local --> pkg_invariants
pkg_tasks_local --> pkg_tasks
pkg_tasks_local --> pkg_timeout
pkg_session_telemetry_otel --> pkg_brand
pkg_session_telemetry_otel --> pkg_invariants
pkg_session_telemetry_otel --> pkg_llm
pkg_session_telemetry_otel --> pkg_paths
pkg_session_telemetry_otel --> pkg_session
pkg_session_telemetry_otel --> pkg_session_telemetry
pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_invariants
pkg_agent_loop --> pkg_llm
@@ -872,6 +867,14 @@ flowchart TD
pkg_client_ui_goal --> pkg_client_ui_slots
pkg_client_ui_goal --> pkg_goal
pkg_client_ui_goal --> pkg_invariants
pkg_client_ui_skill --> pkg_client_connection
pkg_client_ui_skill --> pkg_client_locale
pkg_client_ui_skill --> pkg_client_runtime
pkg_client_ui_skill --> pkg_client_ui_conversation
pkg_client_ui_skill --> pkg_client_ui_primitives
pkg_client_ui_skill --> pkg_client_ui_slash
pkg_client_ui_skill --> pkg_client_ui_slots
pkg_client_ui_skill --> pkg_invariants
pkg_session_reference --> pkg_agent
pkg_session_reference --> pkg_compact
pkg_session_reference --> pkg_invariants
@@ -919,6 +922,13 @@ flowchart TD
pkg_tool_tasks --> pkg_system_prompt
pkg_tool_tasks --> pkg_tasks
pkg_tool_tasks --> pkg_tools
pkg_session_telemetry_otel --> pkg_brand
pkg_session_telemetry_otel --> pkg_command_feedback
pkg_session_telemetry_otel --> pkg_invariants
pkg_session_telemetry_otel --> pkg_llm
pkg_session_telemetry_otel --> pkg_paths
pkg_session_telemetry_otel --> pkg_session
pkg_session_telemetry_otel --> pkg_session_telemetry
pkg_tool_workflow --> pkg_agent
pkg_tool_workflow --> pkg_invariants
pkg_tool_workflow --> pkg_llm
@@ -1203,7 +1213,6 @@ flowchart TD
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) |
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
@@ -1255,9 +1264,9 @@ flowchart TD
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
@@ -1284,6 +1293,7 @@ flowchart TD
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
@@ -1292,6 +1302,7 @@ flowchart TD
| [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |

View File

@@ -221,7 +221,7 @@ Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/
'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string }
```
Source: [`packages/ui/commands/src/index.ts:138`](../packages/ui/commands/src/index.ts)
Source: [`packages/ui/commands/src/index.ts:145`](../packages/ui/commands/src/index.ts)
#### `command/run` — log-only
@@ -233,12 +233,13 @@ Source: [`packages/ui/commands/src/index.ts:138`](../packages/ui/commands/src/in
* and `args` are `parseCommand`'s own split (name and verbatim rawInput,
* separator whitespace included), so a consumer (a projection unit
* folding its own command records, a rich command card) never re-parses
* a line.
* a line. `args` is absent when the definition sets `recordInput: false`
* because an authoritative domain event owns the input payload.
*/
'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource }
'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource }
```
Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/index.ts)
Source: [`packages/ui/commands/src/index.ts:139`](../packages/ui/commands/src/index.ts)
### `compact/*`
@@ -330,6 +331,20 @@ Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-st
Source: [`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts)
### `feedback/*`
#### `feedback/record` — log-only
```ts persistence-catalog
/**
* One recorded human remark about this session. Log-only and independent
* of its trigger; it never enters the model surface or derived history.
*/
'feedback/record': { text: string }
```
Source: [`packages/feedback/command-feedback/src/index.ts:24`](../packages/feedback/command-feedback/src/index.ts)
### `goal/*`
#### `goal/change` — log-only

View File

@@ -11,6 +11,7 @@ import { createServer } from 'node:http'
import { once } from 'node:events'
import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts'
import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
const configPath = process.argv[2]
if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path')
@@ -35,6 +36,15 @@ try {
// The fixture credential rides the model-visible user message; the exported
// copy must scrub it while the canonical log keeps the original bytes.
await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' })
const mode = process.env.DSH_TELEMETRY_E2E_MODE ?? 'FULL'
if (mode !== 'FULL') {
const [agent] = ctx.get('agents')?.roots() ?? []
if (agent === undefined) throw new Error('telemetry-otel driver requires one root agent')
recordFeedback(agent.session, 'fixture feedback')
if (mode === 'FEEDBACK_ONLY') {
await runOneShot(ctx, { task: 'post-feedback private suffix' })
}
}
} finally {
await ctx.fiber.dispose()
}

View File

@@ -2,6 +2,14 @@
# path, exporting to the mock OTLP collector the driver starts (url via env).
# The redact-rule entry models a deployment mounting its own scrub rule on the
# telemetry/record waterfall — the seam itself ships no rules.
- id: logger-console
name: '@cordisjs/plugin-logger-console'
config:
colors: false
levels:
default: 3
showTime: ''
- id: cli-mock-llm
name: './cli-mock-llm.ts'
@@ -18,6 +26,7 @@
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: !!js process.env.DSH_TELEMETRY_E2E_MODE || 'FULL'
exporter:
url: !!js process.env.DSH_TELEMETRY_E2E_URL

View File

@@ -7,6 +7,7 @@
"dependencies": {
"@cordisjs/plugin-hmr": "workspace:*",
"@cordisjs/plugin-include": "workspace:*",
"@cordisjs/plugin-logger-console": "workspace:*",
"@cordisjs/plugin-timer": "workspace:*",
"@deepseek-ai/dsh-acp-demo": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:*",
@@ -19,6 +20,7 @@
"@deepseek-ai/dsh-bash-sandbox": "workspace:*",
"@deepseek-ai/dsh-cli-demo": "workspace:*",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:*",
"@deepseek-ai/dsh-command-feedback": "workspace:*",
"@deepseek-ai/dsh-command-goal": "workspace:*",
"@deepseek-ai/dsh-commands": "workspace:*",
"@deepseek-ai/dsh-compact": "workspace:*",

View File

@@ -65,6 +65,7 @@
"**/*.ts"
],
"ignoreDependencies": [
"@cordisjs/plugin-logger-console",
"@deepseek-ai/.+"
]
},

View File

@@ -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/README.md
README.md: b736aa5dc9d0e9313d652d40c3f4834456dccbb4
README.zh.md: 53081b5e8c2d465dc866644eae78bebf0c4fc3a1
README.md: baf1680dda6f379f61f91afe327034e2aa82e202
README.zh.md: bb5c9cd610c4051e586d9ad323c36305613d6c20

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions).
## Hierarchy
@@ -12,7 +12,8 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|---|---|---|
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface |
| [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface |
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
@@ -26,7 +27,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, worker-thread engine, and model-facing `workflow` and fresh-agent `ralph` tools | Product — stable surface |
| [`workflow/`](workflow/README.md) | Script seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
所有包都使用 `@deepseek-ai/dsh-*` scope。每个包都是 Cordis `Service` 子类函数插件;所有贡献通过 `ctx.effect()``ctx.on()``ctx.waterfall()` 注册。编写规则见[](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类函数插件贡献通过 `ctx.effect()``ctx.on()``ctx.waterfall()` 注册。编写规则见[](AGENTS.md)与[根规则](../AGENTS.md#conventions)。
## 层级结构
@@ -12,7 +12,8 @@
|---|---|---|
| [`core/`](core/README.md) | 产品 API 主干会话、提示词、工具、agent智能体服务与具体循环 | 产品:稳定表面 |
| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定表面 |
| [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 |
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 |
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 |
| [`llm/`](llm/README.md) | LLM大语言模型能力系列抽象服务 + 提供方适配器 | 产品:稳定表面 |
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列spawn seam + 本地进程树实现 | 产品:稳定表面 |
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
@@ -26,7 +27,7 @@
| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定表面 |
| [`subagent/`](subagent/README.md) | Subagent 能力系列:提供方注册表 seam 和面向模型的委托工具 | 产品:稳定表面 |
| [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定表面 |
| [`workflow/`](workflow/README.md) | 工作流能力系列:脚本引擎 seam、worker 线程引擎面向模型的 `workflow` 与新 agent `ralph` 工具 | 产品:稳定表面 |
| [`workflow/`](workflow/README.md) | 脚本 seam、worker 线程引擎面向模型的 `workflow`/`ralph` 工具 | 产品:稳定表面 |
| [`web/`](web/README.md) | Web 能力系列seam、搜索获取提供方实现和面向模型的 Web 工具 | 产品:稳定表面 |
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |

View File

@@ -217,6 +217,9 @@
- id: commands
name: '@deepseek-ai/dsh-commands'
- id: command-feedback
name: '@deepseek-ai/dsh-command-feedback'
- id: goal
name: '@deepseek-ai/dsh-goal'

View File

@@ -39,6 +39,7 @@
"@deepseek-ai/dsh-bash-env": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^",
"@deepseek-ai/dsh-command-feedback": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",

View File

@@ -689,9 +689,9 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
/**
* Fixture parallel of the plan unit's double-event fold: `command/run`
* records named `plan` set the wanted target (`off` → false, else true);
* `plan/mode` commits and clears it. `wanted` is exposed for the prompt
* boundary (the fixture's step/start parallel).
* records named `plan` with recorded input set the wanted target (`off` →
* false, else true); `plan/mode` commits and clears it. `wanted` is exposed
* for the prompt boundary (the fixture's step/start parallel).
*/
function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
let active = false
@@ -700,7 +700,8 @@ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boo
const item = event as unknown as { type: string; data?: Record<string, unknown> }
if (item.type === 'command/run' && item.data?.['name'] === 'plan') {
const args = item.data['args']
wanted = (typeof args === 'string' ? args : '').trim() !== 'off'
if (typeof args !== 'string') continue
wanted = args.trim() !== 'off'
} else if (item.type === 'plan/mode') {
active = item.data?.['active'] === true
wanted = null
@@ -1007,9 +1008,11 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
seq: event.seq,
}]
}
// The plan unit advances on its two folded event kinds.
// The plan unit advances on its two folded event kinds when the command
// lifecycle contains the input that represents a plan selection.
const commandData = event as unknown as { data: { name?: string; args?: unknown } }
if (type === 'plan/mode' || (type === 'command/run'
&& (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
&& commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) {
return [{
type: 'session/projection',
sessionId: id,

View File

@@ -230,7 +230,10 @@ export interface CommandNode {
commandId: CommandId
/** Command name (run payload's structured field); null when the run fell outside the window. */
name: string | null
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
/**
* Verbatim rawInput after the name, including separator whitespace; null
* when omitted by the command or when the run fell outside the window.
*/
args: string | null
/** Settlement outcome (done payload); null while the command is still executing. */
outcome: { kind: 'success' | 'error'; text?: string } | null

View File

@@ -313,10 +313,10 @@ export class TranscriptAdapter {
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
const data = event.data as unknown as { commandId: CommandId; name: string; args?: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null,
})
return true
}

View File

@@ -90,6 +90,8 @@ export const ev = {
} }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }),
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
/** A compaction's log-only `compact/summary` provenance record. */

View File

@@ -432,6 +432,14 @@ describe('TranscriptAdapter', () => {
expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null })
})
it('represents command input omitted by the host as null', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')])
expect(adapter.nodes()[0]).toMatchObject({
kind: 'command', name: 'feedback', args: null, outcome: null,
})
})
it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => {
const adapter = new TranscriptAdapter()
adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')])

View File

@@ -23,7 +23,7 @@
import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
CodeBlock, DiffBlock, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -107,15 +107,6 @@ export interface ToolRowProps {
inspect?: (() => void) | undefined
}
/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
function IconInspect() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
)
}
/** Leading-slot state substitution: the tool icon yields to the terminal state
* semantic (error = red, interrupted = amber halo). Running keeps the icon —
* the row sweep (CSS on data-state) carries the in-flight signal. */
@@ -332,7 +323,7 @@ export function ToolRow({
className={css.inspectButton}
onClick={inspect}
>
<IconInspect />
<IconInspectOutline12 />
Inspect
</button>
)}

View File

@@ -17,7 +17,7 @@ import { useState, type KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import clsx from 'clsx'
import {
IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
IconApiOutline14, IconChevronDownOutline14, IconInspectOutline12, StateDot, TerminalBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolRowProps } from '../contract/slots.ts'
@@ -153,9 +153,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
)}
{inspect !== undefined && (
<button type="button" className={css.inspectButton} onClick={inspect}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
<IconInspectOutline12 />
Inspect
</button>
)}

View File

@@ -750,6 +750,27 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** inspect_outline_12 (shared tool-row trajectory affordance glyph) */
export const IconInspectOutline12 = ({ size = 12, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
)
/** skill_outline_16 (skill tool-row glyph; document instructions + sparkle) */
export const IconSkillOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.5113 15.4067C12.4395 15.6249 12.1308 15.6249 12.059 15.4067L11.643 14.1416C11.454 13.567 11.0033 13.1164 10.4288 12.9274L9.16369 12.5113C8.94544 12.4395 8.94544 12.1308 9.16369 12.059L10.4288 11.643C11.0033 11.454 11.454 11.0033 11.643 10.4288L12.059 9.16369C12.1308 8.94544 12.4395 8.94544 12.5113 9.16369L12.9274 10.4288C13.1164 11.0033 13.567 11.454 14.1416 11.643L15.4067 12.059C15.6249 12.1308 15.6249 12.4395 15.4067 12.5113L14.1416 12.9274C13.567 13.1164 13.1164 13.567 12.9274 14.1416L12.5113 15.4067Z"
fill="currentColor"
/>
<path
d="M9.02246 0.546878C9.9822 0.546878 10.7564 0.545403 11.374 0.612307C12.0042 0.680586 12.5515 0.826244 13.0273 1.17188C13.3052 1.37376 13.5501 1.61868 13.752 1.89649C14.0975 2.37225 14.2432 2.91984 14.3115 3.54981C14.3784 4.16727 14.377 4.94206 14.377 5.90137V8.51367C13.9611 8.29533 13.5071 8.13985 13.0273 8.06055V5.90137C13.0273 4.9121 13.0259 4.22322 12.9688 3.69532C12.9129 3.18044 12.8098 2.89782 12.6592 2.69043C12.5406 2.52724 12.3966 2.38326 12.2334 2.26465C12.026 2.11404 11.7437 2.0109 11.2285 1.95508C10.7005 1.89789 10.0122 1.89649 9.02246 1.89649H6.55371C5.56395 1.89649 4.87569 1.89787 4.34766 1.95508C3.83242 2.01092 3.55022 2.11398 3.34278 2.26465C3.17953 2.38329 3.03564 2.52719 2.91699 2.69043C2.76642 2.89782 2.66325 3.18042 2.60742 3.69532C2.55027 4.22322 2.54883 4.9121 2.54883 5.90137V10.0986C2.54883 11.0878 2.55031 11.7768 2.60742 12.3047C2.66326 12.8196 2.76642 13.1032 2.91699 13.3105C3.03558 13.4736 3.17966 13.6178 3.34278 13.7363C3.5502 13.8869 3.83265 13.9901 4.34766 14.0459C4.87568 14.1031 5.56398 14.1035 6.55371 14.1035H8.08399C8.27443 14.6025 8.55077 15.0585 8.89551 15.4541H6.55371C5.59402 15.4541 4.81976 15.4546 4.20215 15.3877C3.57204 15.3194 3.02468 15.1738 2.54883 14.8281C2.27111 14.6263 2.02606 14.3813 1.82422 14.1035C1.47883 13.6278 1.33293 13.08 1.26465 12.4502C1.19783 11.8327 1.19922 11.0579 1.19922 10.0986V5.90137C1.19922 4.94206 1.1978 4.16727 1.26465 3.54981C1.33295 2.91984 1.47867 2.37225 1.82422 1.89649C2.02613 1.61864 2.27098 1.37379 2.54883 1.17188C3.02472 0.826181 3.57197 0.6806 4.20215 0.612307C4.81976 0.545393 5.594 0.546877 6.55371 0.546878H9.02246ZM9.19629 9.14649H4.5459V7.84571H9.19629V9.14649ZM11.0303 6.10645H4.5459V4.80567H11.0303V6.10645Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_question_outline_14 (figma extract): ring + question glyph. */
export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (46 deepsuite + 17 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(64)
it('exports the full P-I set (46 deepsuite + 17 figma extracts + three product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(66)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {

View File

@@ -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-skill/README.md
README.md: fc83ae47dc83e72d60f382892aa678989902d217
README.zh.md: e103db812d2a21f7f211bc843ec0cd31d1dc2c1e
README.md: a9506fe563b94fb4d1f9afd882216e023b0c2d13
README.zh.md: 6af5d3eb8820dacc2ab569be8b830481dd45fb9a

View File

@@ -8,6 +8,10 @@ A failed `skill.list` throws from `candidates`, which the slash shell logs and f
The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
## Skill tool row
The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 16-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from a paired call/result slice in the current runtime window, never from the current catalog, so replay remains stable when installed skills or their descriptions change.
## Model Experience
### Skill reference text in the user prompt
@@ -26,6 +30,7 @@ Append-only: the reference is part of a new user message appended after the reus
## Known Limitations and Deferred Work
- **Result-only history pages use the generic row** — keyed dispatch needs the paired call in the runtime window; pagination that leaves the call outside has no tool identity. This client presentation feature does not extend the history wire contract to recover it.
- **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change.
- **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog.
- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item).

View File

@@ -8,6 +8,10 @@ skill技能引用 source 的浏览器端:把 `/` 触发的 `skill` sourc
`/client` 导出表层只有插件主体(`apply``inject`source 对象是注册 effect 的内部实现。
## skill 工具行
浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 16 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript文本记录的扫光效果失败时用错误首行替换名称中断调用则使用警告状态。已结算的行以整行作为展开入口展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自当前 runtime 窗口中已配对的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。
## 模型体验
### 用户提示词中的 skill 引用文本
@@ -26,6 +30,7 @@ skill技能引用 source 的浏览器端:把 `/` 触发的 `skill` sourc
## 已知限制与暂缓事项
- **仅含结果的 history 页使用通用行**:键控分派要求配对调用位于 runtime 窗口内;分页将调用留在窗口外时,结果没有工具身份。这项客户端呈现功能不会为了恢复该身份而扩展 history 协议契约。
- **skill 加载具有非确定性**引用是协作线索不是保证模型可能忽略它。针对命中率不足情况的返工路径host 侧 `context/skill-reference` 引导包,或全文注入)记录在设计台账中;协议中的文本形态不会改变。
- **首次击键可能与预热竞速**scope 创建时的预热会启动目录拉取,但目录落定之前打开的菜单,在那次击键下不会显示 skill 候选。这是设计上接受的取舍skill 引用不参与回车裁决,因此没有任何攸关正确性的环节等待目录。
- **文本是唯一依据**:引用是普通的草稿文本;手动键入的相同 token 就是同一个引用。chip 视觉由 lexicon 扫描派生;没有 occurrence 身份或位置跟踪(组件化 chip 是台账事项)。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-skill",
"description": "Skill reference source: '/' menu candidates from skill.list, inserts <skill>name</skill> references",
"description": "Web skill references and the dedicated skill tool row",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -25,6 +25,8 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-slash"
],
"platform": "web"
@@ -36,19 +38,31 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@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",
"cordis": "^4.0.0-rc.7"
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",

View File

@@ -0,0 +1,212 @@
/* Skill toolview: Bash-matched summary row plus a bounded instructions disclosure. */
.card {
display: flex;
flex-direction: column;
}
.row {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
.row[data-expandable] {
cursor: pointer;
}
.card[data-state='running'] .row::after {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-skill-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-skill-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
position: relative;
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.row:hover .iconIdle {
opacity: 0;
}
.row:hover .chevronHover {
opacity: 1;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.separator {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.errorSummary {
color: var(--dsw-alias-state-error-primary);
}
.bodyWrap {
display: flex;
flex-direction: column;
}
.instructionsCard {
display: flex;
flex-direction: column;
max-height: 260px;
margin: 4px 0 4px 4px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 12px;
background: var(--dsw-alias-markdown-code-block);
}
.instructionsHeader {
flex: none;
padding: 8px 12px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-markdown-code-block-banner);
font-size: 11px;
font-weight: 500;
line-height: 16px;
color: var(--dsw-alias-label-caption);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.instructions {
min-height: 0;
margin: 0;
padding: 10px 12px 12px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-secondary);
}
.instructions[data-error] {
color: var(--dsw-alias-state-error-primary);
}
.instructions::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
.instructions::-webkit-scrollbar-track {
margin: 6px 0;
}
.inspectButton {
display: inline-flex;
align-self: flex-start;
align-items: center;
gap: 4px;
margin: 4px 0 2px 4px;
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 16px;
cursor: pointer;
opacity: 0;
transition: opacity 100ms ease;
}
.card:hover .inspectButton,
.inspectButton:focus-visible {
opacity: 1;
}
.inspectButton:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
color: var(--dsw-alias-label-primary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.card[data-state='running'] .row::after {
animation: none;
display: none;
}
.iconIdle,
.chevronHover,
.inspectButton {
transition: none;
}
}

View File

@@ -0,0 +1,171 @@
// Skill toolview registrant: a domain-owned row over the keyed toolview hole.
// The compact accent row keeps loaded instructions scannable in the transcript;
// the exact durable tool output remains available in a bounded disclosure card.
import { useState, type KeyboardEvent, type ReactNode } from 'react'
import {
IconChevronDownOutline14, IconInspectOutline12, IconSkillOutline16, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import css from './SkillRow.module.css'
/** Skill row lifecycle derived solely from the durable call slice. */
type SkillRowState = 'running' | 'ok' | 'error' | 'stopped'
/** Full row props: the toolview runtime share plus this package's locale seat. */
type SkillRowProps = ToolRowProps & PropsLocale<'skill'>
/** Compact, replay-stable view model for the dedicated row. */
interface SkillRowModel {
readonly name: string
readonly output: string | null
readonly errorSummary: string | null
readonly state: SkillRowState
}
/** First physical line for the collapsed error summary and malformed-args fallback. */
function firstLine(text: string): string {
const newline = text.indexOf('\n')
return newline === -1 ? text : text.slice(0, newline)
}
/** Skill names are the only call argument the compact row presents. */
function skillName(argsRaw: string, callId: string): string {
try {
const parsed = JSON.parse(argsRaw) as unknown
if (typeof parsed === 'object' && parsed !== null) {
const name = (parsed as Record<string, unknown>).name
if (typeof name === 'string' && name !== '') return firstLine(name)
}
} catch {
// Streaming can expose a truncated JSON prefix; its first line is still
// more useful than replacing the call with an unrelated catalog lookup.
}
return argsRaw === '' ? callId : firstLine(argsRaw)
}
/** Flatten durable result blocks under the generic tool-row text contract.
* Keep aligned with ui-conversation's contract/tool-call-model.ts `resultText`. */
function resultText(block: ToolRowProps['block']): string | null {
if (!('kind' in block)) return null
const parts: string[] = []
for (const item of block.content) {
parts.push(item.type === 'text' ? item.text : JSON.stringify(item, null, 2))
}
if (parts.length === 0 && block.error !== undefined) {
parts.push(`${block.error.name}: ${block.error.code}`)
}
return parts.join('\n') || null
}
/** Derive display state without consulting the live skill catalog. */
function skillRowModel(block: ToolRowProps['block']): SkillRowModel {
const settled = 'kind' in block
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
const state: SkillRowState = !settled
? 'running'
: block.error?.code === 'interrupted'
? 'stopped'
: block.isError ? 'error' : 'ok'
const output = resultText(block)
return {
name: skillName(argsRaw, block.callId),
output,
errorSummary: state === 'error' && output !== null ? firstLine(output) : null,
state,
}
}
/** State substitution for the collapsed leading slot. */
function leadingFor(state: SkillRowState): ReactNode {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconSkillOutline16 />
}
}
/** Leading disclosure slot: state icon at rest, chevron on hover or while open. */
function disclosureLeading(state: SkillRowState, open: boolean, expandable: boolean): ReactNode {
if (open) return <IconChevronDownOutline14 className={css.chevron} />
const icon = leadingFor(state)
if (!expandable) return icon
return (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={`${css.chevron} ${css.chevronHover}`} />
</>
)
}
/** Visually hidden state copy for the colour-only lifecycle cues. */
function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null {
switch (state) {
case 'running': return t('row.running')
case 'error': return t('row.failed')
case 'stopped': return t('row.stopped')
default: return null
}
}
/**
* Render one `skill` tool call as an accent summary and instructions disclosure.
* @param props - keyed toolview payload plus the skill locale seat.
* @returns the dedicated skill row.
*/
export function SkillRow({ block, inspect, t }: SkillRowProps) {
const model = skillRowModel(block)
const [expanded, setExpanded] = useState(false)
const expandable = model.output !== null
const open = expanded && expandable
const status = stateStatus(model.state, t)
const summary = model.errorSummary ?? model.name
const toggleExpand = (): void => {
setExpanded(value => !value)
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>): void => {
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
const disclosureProps = expandable ? {
role: 'button' as const,
tabIndex: 0,
'aria-expanded': open,
onClick: toggleExpand,
onKeyDown: toggleFromKeyboard,
} : {}
const leading = disclosureLeading(model.state, open, expandable)
return (
<div className={css.card} data-tool="skill" data-state={model.state}>
<div
className={css.row}
data-expandable={expandable || undefined}
{...disclosureProps}
>
<span className={css.leading}>{leading}</span>
{status !== null ? <span className={css.visuallyHidden}>{status}</span> : null}
<span className={css.title}>Skill</span>
<span className={css.separator} aria-hidden />
<span className={model.errorSummary === null ? css.summary : `${css.summary} ${css.errorSummary}`}>
{summary}
</span>
</div>
{open ? (
<div className={css.bodyWrap}>
<section className={css.instructionsCard} aria-label={t('row.instructions')}>
<div className={css.instructionsHeader}>{t('row.instructions')}</div>
<pre className={css.instructions} data-error={model.state === 'error' || undefined}>{model.output}</pre>
</section>
{inspect !== undefined ? (
<button type="button" className={css.inspectButton} onClick={inspect}>
<IconInspectOutline12 />
Inspect
</button>
) : null}
</div>
) : null}
</div>
)
}

View File

@@ -19,10 +19,24 @@
* not kill the prewarm other consumers will hit, so it carries its own
* abort (fired only on invalidation/teardown) while a candidates caller
* with an aborted signal just returns early.
*
* This browser half also owns the `skill` keyed toolview: a replay-stable
* accent row derived only from each logged call/result slice.
*/
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { SkillRow } from './SkillRow.tsx'
import { en, NS, zh, type SkillKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The dedicated skill tool row's copy. */
skill: SkillKey
}
}
/** One session's catalog fetch: the shared promise plus its own abort handle. */
interface CatalogFetch {
@@ -32,14 +46,20 @@ interface CatalogFetch {
settled?: readonly SkillEntry[]
}
/** Required services: slash registry, routed sessions, and the wire face. */
export const inject = ['slash', 'connection', 'sessions']
/** Required services: reference source faces plus the tool-row and locale registries. */
export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale']
/**
* Client plugin body: register the '/' skill source over the root wire face.
* Client plugin body: register the '/' source, dictionaries, and keyed tool row.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-skill: dictionaries')
ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register(
{ name: 'conversation.chat.toolview', key: 'skill', locale: NS },
SkillRow,
))
const skills = (ctx.get('connection') as ConnectionHandle).api.skills
const sessions = ctx.get('sessions') as ISessions
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:

View File

@@ -0,0 +1,23 @@
/** `skill` namespace dictionaries for the dedicated tool row. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'skill'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'row.running': '正在加载 skill',
'row.failed': 'skill 加载失败',
'row.stopped': 'skill 加载已中止',
'row.instructions': '说明',
} satisfies Record<string, string>
/** The skill namespace key union. */
export type SkillKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'row.running': 'Loading skill',
'row.failed': 'Skill load failed',
'row.stopped': 'Skill load stopped',
'row.instructions': 'Instructions',
} satisfies Record<SkillKey, string>

View File

@@ -15,9 +15,10 @@ export const name = 'client-ui-skill-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a single slash-source registration whose disposal is
* proven by the HMR-safety spec — it emits no cordis events and owns no
* cross-plugin mutable state.
* No runtime invariant: the slash source, locale dictionaries, and keyed
* toolview are registry-owned registrations whose disposal is proven by the
* HMR-safety spec. They emit no cordis events and own no cross-plugin mutable
* state.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,5 +1,6 @@
/**
* ui-skill browser half: source registration (duplicate-name proof) +
* ui-skill browser half: source and keyed toolview registration +
* locale dictionaries + source duplicate-name proof +
* fiber-teardown removal (HMR safety) against the real SlashService, then
* the source behavior contract driven directly on the captured source with
* real ClientSessionContext projections — sessionId addressing, the
@@ -13,9 +14,11 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import { apply, inject } from '../src/client/index.ts'
import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx'
type SkillRow = { name: string; description: string; whenToUse?: string }
type ListResult =
@@ -23,6 +26,33 @@ type ListResult =
| { ok: false; error: { code: string; message: string; details: object } }
type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }>
interface PresentationCapture {
slots: SlotsService
dictionaries: Array<{ namespace: string; dictionaries: unknown }>
localeDisposed: boolean
}
/** Provide the presentation registries and capture the plugin's registrations. */
function providePresentation(ctx: Context): PresentationCapture {
const slots = new SlotsService(ctx)
slots.register({
name: 'root',
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
} as never, () => null)
const capture: PresentationCapture = {
slots,
dictionaries: [],
localeDisposed: false,
}
ctx.provide('locale', {
register(namespace: string, dictionaries: unknown) {
capture.dictionaries.push({ namespace, dictionaries })
return () => { capture.localeDisposed = true }
},
})
return capture
}
/** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
async function bench(list: ListFn, addressed?: SessionId) {
const ctx = new Context()
@@ -34,6 +64,7 @@ async function bench(list: ListFn, addressed?: SessionId) {
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
})
providePresentation(ctx)
await ctx.plugin({ inject: [...inject], apply }).await()
return { ctx, source: captured! }
}
@@ -65,7 +96,36 @@ const req = (query: string, signal?: AbortSignal) =>
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'connection', 'sessions'])
expect(inject).toEqual(['slash', 'connection', 'sessions', 'slots', 'locale'])
})
it('registers the dedicated skill row and its locale dictionaries', async () => {
const ctx = new Context()
ctx.provide('slash', { registerSource: () => () => {} })
ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } })
ctx.provide('sessions', { subagentAddress: () => undefined })
const presentation = providePresentation(ctx)
await ctx.plugin({ inject: [...inject], apply }).await()
const entry = presentation.slots.entries('conversation.chat.toolview')[0]
expect(entry?.options).toMatchObject({ key: 'skill' })
expect(entry?.locale).toBe('skill')
expect(entry?.component).toBe(SkillToolRow)
expect(presentation.dictionaries).toEqual([{
namespace: 'skill', dictionaries: {
zh: {
'row.running': '正在加载 skill',
'row.failed': 'skill 加载失败',
'row.stopped': 'skill 加载已中止',
'row.instructions': '说明',
},
en: {
'row.running': 'Loading skill',
'row.failed': 'Skill load failed',
'row.stopped': 'Skill load stopped',
'row.instructions': 'Instructions',
},
},
}])
})
it('registers the "/" skill source; disposal frees the name (HMR safety)', async () => {
@@ -74,6 +134,7 @@ describe('apply', () => {
ctx.provide('sessions', {})
await ctx.plugin(SlashService).await()
ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } })
const presentation = providePresentation(ctx)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const slash = ctx.get('slash') as SlashService
@@ -88,6 +149,8 @@ describe('apply', () => {
// …and fiber teardown releases it.
await fiber.dispose()
expect(() => slash.registerSource(rival)).not.toThrow()
expect(presentation.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(presentation.localeDisposed).toBe(true)
})
})

View File

@@ -0,0 +1,152 @@
// @vitest-environment jsdom
// Dedicated skill tool row: replay-stable naming, lifecycle states, disclosure,
// keyboard operation, exact output, and the trajectory Inspect handoff.
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { SkillRow } from '../src/client/SkillRow.tsx'
import { zh } from '../src/client/locales.ts'
type SkillRowProps = Parameters<typeof SkillRow>[0]
const t: SkillRowProps['t'] = makeTranslate(zh, commonZh)
afterEach(cleanup)
function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
return {
kind: 'tool-result',
seq: 3,
time: 3_000,
callId: 'call-skill',
call: { name: 'skill', argsRaw: '{"name":"dsh-manage-issues"}' },
callTime: 2_000,
content: [{ type: 'text', text: 'Follow the issue workflow.\nKeep project fields in sync.' }],
isError: false,
callView: null,
resultView: null,
...over,
}
}
function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall {
return {
callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null,
}
}
function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowProps {
return {
callId: block.callId,
toolName: 'skill',
block,
openFile: vi.fn(),
inspect,
t,
} as unknown as SkillRowProps
}
describe('SkillRow', () => {
it('renders a compact Bash-shaped summary and discloses the exact instructions', () => {
const inspect = vi.fn()
const view = render(<SkillRow {...props(settled(), inspect)} />)
const row = screen.getByRole('button', { name: 'Skilldsh-manage-issues' })
expect(row.getAttribute('aria-expanded')).toBe('false')
expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok')
expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('16')
expect(screen.queryByLabelText('说明')).toBeNull()
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
const card = screen.getByLabelText('说明')
expect(card.textContent).toBe('说明Follow the issue workflow.\nKeep project fields in sync.')
expect(view.container.textContent).not.toContain('{"name":"dsh-manage-issues"}')
fireEvent.click(screen.getByRole('button', { name: 'Inspect' }))
expect(inspect).toHaveBeenCalledTimes(1)
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('supports Enter and Space while ignoring unrelated keys', () => {
render(<SkillRow {...props(settled())} />)
const row = screen.getByRole('button')
fireEvent.keyDown(row, { key: 'Escape' })
expect(row.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(row, { key: 'Enter' })
expect(row.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(row, { key: ' ' })
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('keeps a running call compact and announces its state', () => {
const view = render(<SkillRow {...props(running())} />)
const row = view.container.querySelector('[data-tool="skill"] > div')!
expect(row.getAttribute('role')).toBeNull()
expect(view.container.textContent).toContain('正在加载 skill')
expect(view.container.textContent).toContain('dsh-manage-issues')
expect(view.container.querySelector('svg [fill="currentColor"]')).not.toBeNull()
})
it('uses the first failure line in the summary and exposes the full error', () => {
const view = render(<SkillRow {...props(settled({
content: [{ type: 'text', text: 'SkillError: missing resource\nCheck SKILL.md.' }],
isError: true,
error: { name: 'SkillError', code: 'missing' },
}))} />)
const row = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing resource' })
expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error')
expect(row.textContent).not.toContain('Check SKILL.md.')
fireEvent.click(row)
const output = view.container.querySelector('pre')!
expect(output.textContent).toBe('SkillError: missing resource\nCheck SKILL.md.')
expect(output.getAttribute('data-error')).toBe('true')
})
it('renders stopped, structured, and structured-error durable outcomes', () => {
const stoppedView = render(<SkillRow {...props(settled({
error: { name: 'InterruptedError', code: 'interrupted' },
}))} />)
expect(stoppedView.container.textContent).toContain('skill 加载已中止')
expect(stoppedView.container.querySelector('[data-state="warning"]')).not.toBeNull()
cleanup()
const structuredView = render(<SkillRow {...props(settled({
content: [{ type: 'reasoning', text: 'structured instruction note' }],
}))} />)
fireEvent.click(screen.getByRole('button'))
expect(structuredView.container.textContent).toContain('"type": "reasoning"')
cleanup()
render(<SkillRow {...props(settled({
content: [],
isError: true,
error: { name: 'SkillError', code: 'missing' },
}))} />)
const errorRow = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing' })
fireEvent.click(errorRow)
expect(screen.getAllByText('SkillError: missing')).toHaveLength(2)
})
it('falls back to durable args or call id when the skill name is unavailable', () => {
const invalid = render(<SkillRow {...props(running('{"name":\n'))} />)
expect(invalid.container.textContent).toContain('{"name":')
cleanup()
const scalar = render(<SkillRow {...props(running('"raw-name"'))} />)
expect(scalar.container.textContent).toContain('"raw-name"')
cleanup()
const emptyName = render(<SkillRow {...props(running('{"name":""}'))} />)
expect(emptyName.container.textContent).toContain('{"name":""}')
cleanup()
const blank = render(<SkillRow {...props(settled({ call: null, content: [] }))} />)
expect(blank.container.textContent).toContain('call-skill')
expect(blank.container.querySelector('[role="button"]')).toBeNull()
expect(blank.container.textContent).not.toContain('正在加载 skill')
})
})

View File

@@ -14,9 +14,18 @@
{
"path": "../connection"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slash"
},

View File

@@ -1512,7 +1512,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'telemetry/record',
mode: 'waterfall',
signature: '\'telemetry/record\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord',
jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */',
jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Live capture dispatches at append time; on-demand capture\n * dispatches while reading the canonical log. Redaction applies to the\n * exported copy only; the canonical session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */',
summary: 'Transform one outbound record before it reaches the backend.',
},
{
@@ -1785,7 +1785,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CommandDefinition',
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly recordInput?: boolean;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>;\n}',
},
{
name: 'CommandDescriptor',

View File

@@ -160,6 +160,29 @@ export function apply(ctx: Context, config: Config): void {}
expect(entries[0]?.refs).toEqual([{ alias: 'Remote', imported: 'Remote', specifier: '@fix/dep' }])
})
it('pastes an enum referenced by the config type', () => {
const entries = collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'
/** Fixture mode. */
export enum Mode {
A = 'a',
B = 'b',
}
/** Fixture config. */
export interface Config {
/** The mode. */
mode?: Mode
}
/** Load. */
export function apply(ctx: Context, config: Config): void {}
`,
}))
expect(entries[0]?.pastes?.map(p => p.text)).toEqual([
'/** Fixture config. */\nexport interface Config {\n /** The mode. */\n mode?: Mode\n}',
"/** Fixture mode. */\nexport enum Mode {\n A = 'a',\n B = 'b',\n}",
])
})
it('hard-errors on a referenced type name that resolves nowhere', () => {
expect(() => collectConfigCatalog(make({
'src/index.ts': `import type { Context } from 'cordis'

View File

@@ -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 packages/feedback/README.md
README.md: d2a4a5a27e1c661d2f62b328578fd890a0c622ee
README.zh.md: 2fa42e3bb5f05dfc425356f302f44e497b100f24

View File

@@ -0,0 +1,11 @@
# feedback/ — recorded human feedback
English | [中文](README.zh.md)
The feedback family lets a human record a remark about the session without acting on it. Feedback is durable session-log content, separate from the model conversation and from any policy that might later read it.
| Package | Role | ctx key |
|---|---|---|
| `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — |
A recorded remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../telemetry/session-telemetry-otel/) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy.

View File

@@ -0,0 +1,11 @@
# feedback/:记录的人类反馈
[English](README.md) | 中文
feedback 家族让人类记录对会话的评价,但不据此采取任何动作。反馈属于持久的会话日志内容,与模型对话以及后续可能读取它的任何策略相互独立。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 |
被记录的评价仅写入日志:它绝不会进入模型 surface 或派生历史。挂载后,[`dsh-session-telemetry-otel`](../telemetry/session-telemetry-otel/) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。

View File

@@ -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 packages/feedback/command-feedback/README.md
README.md: 1923267eb3a25a4be564fa4f4535f7a3459ca481
README.zh.md: 674665167030aa6214e9acaa7e6f5314a78c5b14

View File

@@ -0,0 +1,59 @@
# @deepseek-ai/dsh-command-feedback
English | [中文](README.zh.md)
Trigger-independent session feedback plus human-facing `/feedback` capture. The package exports `recordFeedback(session, text)`, which appends one log-only `feedback/record` event. Its plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped Web client executes it without a model turn.
## Command contract
| Input | Result |
|---|---|
| `/feedback <text>` | Append `feedback/record` and acknowledge with `Feedback recorded.` |
| `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. |
Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged.
## What this plugin does and does not do
`recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../telemetry/session-telemetry-otel/) consumer observes the event without changing its capture contract.
The feedback text appears in exactly one durable payload: `feedback/record`. [`dsh-commands`](../../ui/commands/README.md) still appends its generic `command/run` / `command/done` pairing, but this definition sets `recordInput: false`, so `command/run` omits `args`; the paired `command/done` carries only the outcome. All three events are log-only and absent from the ordered surface, `deriveMessages()`, and model requests. These appends start persistence's ordinary eager drain, but neither producer forces `session/flush`, so acknowledgement means the feedback is in the log, not that it has reached disk. Rejected empty input leaves only the command pairing settled as `kind: 'error'`, with no `feedback/record`.
The event is authoritative rather than the command record because feedback may arrive through a trigger other than `/feedback`. Keeping the payload out of `command/run` avoids two records carrying the same text.
## Composition
The producer injects only `commands`. A custom app mounts the registry plus this plugin:
```yaml
- id: commands
name: '@deepseek-ai/dsh-commands'
- id: command-feedback
name: '@deepseek-ai/dsh-command-feedback'
```
The shipped `dsh` base mounts this command unconditionally; it has no configuration and no dependency on the persisted-goal stack. The Web client exposes it through the command adapter. Headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so they do not expose it.
## Model Experience
### Human `/feedback` capture
#### What the model sees
Nothing. The slash input, `feedback/record`, and the acknowledgement are absent from model requests. The feedback event and registry lifecycle records are log-only and carry no `surfaceOp`, so they never reach the ordered surface, `deriveMessages()`, or a system prompt. Recording feedback during a turn does not change that turn's remaining requests.
#### Token effect
Zero direct token effect. Neither an accepted entry nor a usage error adds model tokens, in the recording turn or any later one.
#### KV Cache effect
Independent of the model request path. Recording appends to the session log only, leaving an already-reusable request prefix untouched. Nothing this package contributes can invalidate cache reuse.
## Known Limitations and Deferred Work
- **No feedback retrieval or management surface** — the optional OTel plugin uses the event only as a sharing trigger. There is no retrieval, aggregation, categorization, or model-facing tool for `feedback/record`.
- **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text.
- **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one.
- **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`.
- **Web only in the shipped front doors** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there.

View File

@@ -0,0 +1,59 @@
# @deepseek-ai/dsh-command-feedback
[English](README.md) | 中文
与触发方式无关的会话反馈,以及面向用户的 `/feedback` 采集。本包导出 `recordFeedback(session, text)`,后者追加一个仅写入日志的 `feedback/record` 事件。该插件通过 [`ctx.commands`](../../ui/commands/README.md) 注册一个全局命令,因此每个已组合的命令适配器都能发现它;随附的 Web 客户端无需模型轮次即可执行。
## 命令契约
| 输入 | 结果 |
|---|---|
| `/feedback <text>` | 追加 `feedback/record`,并以 `Feedback recorded.` 确认。 |
| `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 |
前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。
## 本插件做什么、不做什么
`recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../telemetry/session-telemetry-otel/) 消费方会观察该事件,但不改变它的采集契约。
反馈文本只出现在一个持久载荷中:`feedback/record`。[`dsh-commands`](../../ui/commands/README.md) 仍会追加通用的 `command/run` / `command/done` 配对,但此定义设置了 `recordInput: false`,因此 `command/run` 会省略 `args`;配对的 `command/done` 只携带结果。三个事件都仅写入日志,不出现在有序 surface、`deriveMessages()` 以及模型请求中。这些追加会启动持久化的常规即时排空,但两个生产方都不会强制 `session/flush`,因此确认文本表示反馈已进入日志,而不表示它已经落盘。被拒绝的空输入只会留下以 `kind: 'error'` 结算的命令配对,不会产生 `feedback/record`
权威记录是该事件,而不是命令记录,因为反馈可能来自 `/feedback` 之外的触发方式。让载荷不进入 `command/run`,可避免两条记录携带相同文本。
## 组合
生产方只注入 `commands`。自定义应用挂载注册表以及本插件:
```yaml
- id: commands
name: '@deepseek-ai/dsh-commands'
- id: command-feedback
name: '@deepseek-ai/dsh-command-feedback'
```
随附的 `dsh` 基础组合无条件挂载此命令;它没有配置,也不依赖持久 goal 栈。Web 客户端通过命令适配器暴露该命令。无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此不会暴露它。
## 模型体验
### 用户 `/feedback` 采集
#### 模型看到的内容
无。斜杠输入、`feedback/record` 以及确认文本都不出现在模型请求中。反馈事件和注册表生命周期记录仅写入日志且不携带 `surfaceOp`,因此它们绝不会进入有序 surface、`deriveMessages()` 或系统提示词。在某个轮次中记录反馈不会改变该轮次剩余的请求。
#### Token 影响
无直接 token 影响。无论是已接受的条目还是用法错误,都不会在记录所在轮次或此后任何轮次增加模型 token。
#### KV Cache 影响
与模型请求路径无关。记录只追加到会话日志,不触碰已经可复用的请求前缀。本包贡献的任何内容都不会使缓存复用失效。
## 已知限制与暂缓工作
- **没有反馈检索或管理 surface**:可选的 OTel 插件仅将该事件用作共享触发器。本包不为 `feedback/record` 提供检索、聚合、分类或面向模型的工具。
- **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。
- **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone因此错误的条目会一直保留在记录中只能由后续条目取代。
- **没有显式持久化屏障**:确认文本紧随追加而非 flush因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`
- **随附的产品入口中只有 Web 使用此命令**无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。

View File

@@ -0,0 +1,43 @@
{
"name": "@deepseek-ai/dsh-command-feedback",
"description": "Log-only session feedback producer and human-facing slash command",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,63 @@
/**
* Session feedback event plus the human-facing `/feedback` producer. Recording
* appends one authoritative log-only event and does not start model work. The
* append is eager but unflushed, so acknowledgement reports that the entry is
* logged, not that it reached disk.
* @module @deepseek-ai/dsh-command-feedback
*/
import type { Context } from 'cordis'
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands'
import type { Session } from '@deepseek-ai/dsh-session'
export const name = 'command-feedback'
export const inject = ['commands']
const USAGE = 'Usage: /feedback <text>'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* One recorded human remark about this session. Log-only and independent
* of its trigger; it never enters the model surface or derived history.
*/
'feedback/record': { text: string }
}
}
/**
* Record feedback independently of any UI trigger.
* @param session - session the feedback describes.
* @param text - human-authored feedback; surrounding whitespace is discarded.
* @throws {TypeError} when the normalized text is empty.
*/
export function recordFeedback(session: Session, text: string): void {
const normalized = text.trim()
if (normalized.length === 0) throw new TypeError('feedback text must not be empty')
session.append('feedback/record', { text: normalized })
}
/**
* Validate, record, and acknowledge one feedback entry. Returning an error
* leaves no `feedback/record` event.
* @param invocation - receiving agent, raw command input, and UI cancellation.
* @returns an acknowledgement, or a usage error when no feedback text was supplied.
*/
function executeFeedbackCommand(invocation: CommandInvocation): CommandResult {
if (invocation.rawInput.trim().length === 0) {
return { kind: 'error', text: `Feedback text is required. ${USAGE}` }
}
recordFeedback(invocation.agent.session, invocation.rawInput)
return { kind: 'success', text: 'Feedback recorded.' }
}
/** Register the global `/feedback` command for every composed command adapter. */
export function apply(ctx: Context): void {
ctx.commands.register({
name: 'feedback',
description: 'record feedback about this session',
input: { hint: '<text>' },
recordInput: false,
handler: executeFeedbackCommand,
})
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-command-feedback`.
* @module @deepseek-ai/dsh-command-feedback/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-command-feedback'
/** Cordis companion plugin name. */
export const name = 'command-feedback-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: each `feedback/record` is an independent append-only
* fact with no cross-event or mutable-data relationship.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session'
import * as commandFeedback from '@deepseek-ai/dsh-command-feedback'
interface Harness {
readonly ctx: Context
readonly agent: Agent
readonly session: Session
readonly plugin: Awaited<ReturnType<Context['plugin']>>
}
/** Build a live idle agent over a store-owned session, as an app's spine does. */
function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } {
const session = ctx.sessions.create(SessionId(id))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
let status: AgentStatus = 'idle'
const agent: Agent = {
id: session.id,
options: {},
session,
inbox,
ctx: new Context(),
get status() { return status },
send: () => {},
followup: () => {},
steer: () => {},
inject: () => {},
cancel() { status = 'idle' },
runMaintenance: task => task(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
return { agent, session }
}
/** Mount the real command registry and this producer. */
async function harness(): Promise<Harness> {
const ctx = new Context()
await ctx.plugin(CommandService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionStore)
const plugin = await ctx.plugin(commandFeedback)
const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`)
ctx.agents.register(agent)
return { ctx, agent, session, plugin }
}
/** Execute `/feedback` through the same registry boundary as a UI adapter. */
async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: string }> {
const settled = await test.ctx.commands.execute(
test.agent,
`/feedback${suffix}`,
new AbortController().signal,
)
if (settled === undefined) throw new Error('feedback command was not registered')
return settled.result
}
/** Authoritative feedback payloads in log order. */
function feedbackTexts(session: Session): string[] {
return session.events
.filter(event => event.type === 'feedback/record')
.map(event => event.data.text)
}
describe('@deepseek-ai/dsh-command-feedback registration', () => {
it('registers one global command with Loader-safe exports and disposes it', async () => {
const test = await harness()
expect(commandFeedback.name).toBe('command-feedback')
expect(commandFeedback.inject).toEqual(['commands'])
expect('default' in commandFeedback).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback)
expect(test.ctx.commands.list(test.agent)).toContainEqual({
name: 'feedback',
description: 'record feedback about this session',
input: { hint: '<text>' },
})
expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false })
await test.plugin.dispose()
expect(test.ctx.commands.find(test.agent, 'feedback')).toBeUndefined()
})
})
describe('/feedback human command', () => {
it('acknowledges feedback and records its payload exactly once in the domain event', async () => {
const test = await harness()
await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({
kind: 'success',
text: 'Feedback recorded.',
})
expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable'])
const commandRun = test.session.events.find(event => event.type === 'command/run')
expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false)
expect(JSON.stringify(test.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1)
})
it('exports a command-independent feedback producer', async () => {
const test = await harness()
commandFeedback.recordFeedback(test.session, ' recorded outside a command ')
expect(test.session.events.map(event => event.type)).toEqual(['feedback/record'])
expect(feedbackTexts(test.session)).toEqual(['recorded outside a command'])
expect(() => { commandFeedback.recordFeedback(test.session, ' \n\t ') })
.toThrow('feedback text must not be empty')
expect(feedbackTexts(test.session)).toEqual(['recorded outside a command'])
})
it('keeps command bookkeeping around the authoritative feedback event', async () => {
const test = await harness()
await run(test, ' nothing else happens')
expect(test.session.events.map(event => event.type)).toEqual([
'command/run', 'feedback/record', 'command/done',
])
})
it('normalizes surrounding whitespace without parsing command-like content', async () => {
const test = await harness()
await run(test, ' /plan felt SLOW\n\ttwice today ')
expect(feedbackTexts(test.session)).toEqual(['/plan felt SLOW\n\ttwice today'])
})
it('records each entry separately without replacing earlier ones', async () => {
const test = await harness()
await run(test, ' first')
await run(test, ' second')
expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
})
it('records concurrent submissions in dispatch order', async () => {
const test = await harness()
const signal = new AbortController().signal
// Command adapters may dispatch concurrent requests without awaiting one another.
const settled = await Promise.all([
test.ctx.commands.execute(test.agent, '/feedback first', signal),
test.ctx.commands.execute(test.agent, '/feedback second', signal),
])
expect(settled.map(item => item?.result)).toEqual([
{ kind: 'success', text: 'Feedback recorded.' },
{ kind: 'success', text: 'Feedback recorded.' },
])
expect(feedbackTexts(test.session)).toEqual(['first', 'second'])
})
it('keeps every recorded event off the model surface and out of derived history', async () => {
const test = await harness()
await run(test, ' invisible to the model')
for (const event of test.session.events) {
expect('surfaceOp' in event).toBe(false)
expect(test.session.deriveEventMessage(event)).toBeNull()
}
expect(foldSurface(test.session.events).nodes).toEqual([])
expect(test.session.surface.nodes).toEqual([])
expect(test.session.deriveMessages()).toEqual([])
})
it('rejects empty and whitespace-only input as a failed command record', async () => {
const test = await harness()
const expected = {
kind: 'error',
text: 'Feedback text is required. Usage: /feedback <text>',
}
await expect(run(test)).resolves.toEqual(expected)
await expect(run(test, ' \n\t ')).resolves.toEqual(expected)
expect(feedbackTexts(test.session)).toEqual([])
const done = test.session.events.filter(event => event.type === 'command/done')
expect(done.map(event => event.data.kind)).toEqual(['error', 'error'])
for (const event of test.session.events) {
if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false)
}
})
it('records nothing when dispatch rejects an already-cancelled request', async () => {
const test = await harness()
const controller = new AbortController()
controller.abort(new Error('user cancelled the command'))
await expect(test.ctx.commands.execute(test.agent, '/feedback too late', controller.signal))
.rejects.toThrow('user cancelled the command')
expect(test.session.events).toEqual([])
})
})

View File

@@ -0,0 +1,110 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
/** Register one idle agent over a store-owned session, as an app's spine does. */
function agent(ctx: Context): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId('feedback-loader-agent')
const session = ctx.sessions.create(id)
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
let status: AgentStatus = 'idle'
const value: Agent = {
id,
options: {},
session,
inbox,
ctx: scope.ctx,
get status() { return status },
send: () => {},
followup: () => {},
steer: () => {},
inject: () => {},
cancel() { status = 'idle' },
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
describe('/feedback real Loader composition through cordis.yml', () => {
it('boots cordis.yml and records feedback without model-visible output', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-command-feedback-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-agent'",
"- name: '@deepseek-ai/dsh-session'",
"- name: '@deepseek-ai/dsh-commands'",
"- name: '@deepseek-ai/dsh-command-feedback'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-commands', CommandService],
['@deepseek-ai/dsh-command-feedback', CommandFeedback],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
await context.loader.await()
const owner = agent(context)
const signal = new AbortController().signal
// Discoverable through the composed registry, as a UI adapter finds it.
expect(context.commands.list(owner).map(command => command.name)).toContain('feedback')
const accepted = await context.commands.execute(owner, '/feedback the diff view is unreadable', signal)
expect(accepted?.result).toEqual({ kind: 'success', text: 'Feedback recorded.' })
const rejected = await context.commands.execute(owner, '/feedback', signal)
expect(rejected?.result).toEqual({
kind: 'error',
text: 'Feedback text is required. Usage: /feedback <text>',
})
// The domain event owns the payload; generic command bookkeeping omits it.
expect(owner.session.events.map(event => event.type))
.toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done'])
const run = owner.session.events.find(event => event.type === 'command/run')
expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false)
const feedback = owner.session.events.find(event => event.type === 'feedback/record')
expect(feedback?.type === 'feedback/record' && feedback.data.text).toBe('the diff view is unreadable')
expect(JSON.stringify(owner.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1)
// Nothing reached the model.
expect(owner.session.deriveMessages()).toEqual([])
expect(owner.session.surface.nodes).toEqual([])
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../ui/commands"
},
{
"path": "../../core/session"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -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/plan/plan-mode/README.md
README.md: 7273fa1a9be063e208596788eb4ca4f2bd3409a4
README.zh.md: 0878319545593948fcf04fbc3de518641e1ecfbe
README.md: b22e218d1a0350d9dfb6e64fcaed426deaceb33e
README.zh.md: 57ac85577a690d5c560bec39bef851692be03efd

View File

@@ -22,7 +22,7 @@ The Web client consumes the plugin-owned `/plan` command; other front doors may
## Session projection
When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
## Configuration

View File

@@ -22,7 +22,7 @@ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接
## 会话投影
当组合挂载 `ctx.sessionProjections`[`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan``command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`避免已写入日志的请求与运行面分叉。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`host 消费方经 `./types` 获取client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
当组合挂载 `ctx.sessionProjections`[`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args``command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`避免已写入日志的请求与运行面分叉。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`host 消费方经 `./types` 获取client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
## 配置

View File

@@ -246,6 +246,7 @@ export class PlanModeService extends Service {
init: () => ({ active: false, wanted: null }),
apply: (state, event) => {
if (event.type === 'command/run' && event.data.name === 'plan') {
if (event.data.args === undefined) return state
const wanted = event.data.args.trim() !== 'off'
return wanted === state.wanted ? state : { active: state.active, wanted }
}

View File

@@ -1,9 +1,9 @@
/**
* The `plan` projection unit (session-projection RFC's complete example): a
* double-event fold over the session log. `command/run` records named `plan`
* set the wanted target (`off` → false, anything else → true); `plan/mode`
* commits and clears it; `view` derives `{ active, pending }` where pending
* is true only while an outstanding selection differs from the logged state.
* with recorded input set the wanted target (`off` → false, anything else
* → true); `plan/mode` commits and clears it. `view` reports pending only
* while an outstanding selection differs from the logged state.
* Pending is thereby a pure replay quantity — a cold fold answers it without
* the service's in-memory intent. Composition without plan-mode has no `plan`
* key; unloading the fiber removes it (HMR safety).
@@ -88,6 +88,11 @@ describe('plan projection unit', () => {
commandId: CommandId('other-1'), name: 'compact', args: '', source: { kind: 'user' },
})
expect(bench.values().plan).toEqual({ active: true, pending: false })
// A command lifecycle with omitted input carries no plan selection.
bench.session.append('command/run', {
commandId: CommandId('plan-no-input'), name: 'plan', source: { kind: 'user' },
})
expect(bench.values().plan).toEqual({ active: true, pending: false })
runPlanCommand(bench.session, ' off', 1)
expect(bench.values().plan).toEqual({ active: true, pending: true })
commitPlanMode(bench.session, false, 1)

View File

@@ -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/telemetry/README.md
README.md: c390493d4053f9f532c30c3c2291d833554d6a99
README.zh.md: 846a3e276aeaeda7e3456f4e4d4bea577d224a89
README.md: d1910323176738d1ecab8fe8e6c07a2811f0f5cd
README.zh.md: 229cd3acc5ce3dc49a9c862efa824f4ee6a1fa7b

View File

@@ -2,11 +2,9 @@
English | [中文](README.zh.md)
This family projects session activity into outbound telemetry and delegates delivery to a configured reporting backend.
This family projects session activity into outbound telemetry and delegates delivery to a configured reporting backend. The [telemetry decision](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records the reporting boundary; the [mode decision](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md) records immediate, feedback-gated, and disabled delivery.
| Package | Role |
|---|---|
| [`session-telemetry/`](session-telemetry/README.md) | Defines capture, redaction, projection, and backend delivery |
| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | Delivers telemetry through OpenTelemetry logs |
The [telemetry decision](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records the reporting boundary.
| [`session-telemetry/`](session-telemetry/README.md) | Defines capture, redaction, projection, and live or on-demand backend delivery. |
| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | Delivers telemetry through OpenTelemetry logs in `FULL`, `FEEDBACK_ONLY`, or `DISABLED` mode. |

View File

@@ -2,11 +2,9 @@
[English](README.md) | 中文
本家族将会话活动投影为外发遥测,并将投递委派给配置的上报后端。
本家族将会话活动投影为外发遥测,并将投递委派给配置的上报后端。[遥测决策](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录上报边界;[模式决策](../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)记录即时、反馈门控与禁用投递。
| 包 | 职责 |
|---|---|
| [`session-telemetry/`](session-telemetry/README.md) | 定义捕获、脱敏、投影后端投递 |
| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | 通过 OpenTelemetry 日志投递遥测 |
[遥测决策](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录了上报边界。
| [`session-telemetry/`](session-telemetry/README.md) | 定义捕获、脱敏、投影,以及实时或按需后端投递 |
| [`session-telemetry-otel/`](session-telemetry-otel/README.md) | 通过 OpenTelemetry 日志`FULL``FEEDBACK_ONLY``DISABLED` 模式投递遥测 |

View File

@@ -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/telemetry/session-telemetry-otel/README.md
README.md: 3a5d2b3a4b2adfb591cd4e18908f72ed5492fca4
README.zh.md: 50ef72c92800943266ca6ecdb4483dc65ed58d79
README.md: 585995ce409255df9608bc33b76625374bc67669
README.zh.md: 6d2cfa4d492cee7f90d557c83f5c1ab3c730c6e5

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use, plus `user.id` — the harness home's anonymous user id this package owns (`src/user-id.ts`: `$DSH_HOME/.userid`, a random UUID minted on first use; deleting the file resets the identity), carried once per export batch on the Resource rather than per record.
The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam follows session events live, replays the canonical log only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity contains `service.name`/`service.version` from `dsh-llm`'s `APP_IDENTITY` plus this package's anonymous `user.id` (`$DSH_HOME/.userid`, a random UUID created on first use and reset by deleting the file), carried once per export batch rather than per record.
## Config
@@ -10,6 +10,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
@@ -18,15 +19,25 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
processor: {} # optional; passed verbatim to BatchLogRecordProcessor
```
`exporter.url` is required, has no default, and must parse as `http(s)`; `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline and defaults to 3000 ms; a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, however, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag.
| `mode` | Behavior |
|---|---|
| `FULL` | Default. Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. |
| `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix through that event. Later records wait for another feedback event and remain local if none arrives. |
| `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. |
Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`TelemetryMode.FULL`, `TelemetryMode.FEEDBACK_ONLY`, or `TelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above.
Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present.
`exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. In uploading modes, `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline that defaults to 3000 ms, and a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit.
## What leaves the machine
Records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry.
In uploading modes, records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). `FULL` runs redaction at append time; `FEEDBACK_ONLY` retains no telemetry copy and runs the currently mounted rules when feedback triggers canonical-log replay. Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend.
## Field mapping
Seam record → SDK log record: `time``timestamp`/`observedTimestamp`; `severity``severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. One consequence of continuing rather than replaying: a turn left open mid-stream and never closed marks the previous process dying inside it. The local log is repaired with synthetic closers at resume, but those repairs are never exported the wire stream stays faithful to what the crashed process actually shipped, and a later clean `shutdown` marker attests only to the resumed process's own exit.
Seam record → SDK log record: `time``timestamp`/`observedTimestamp`; `severity``severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)` and alert on severity. In `FULL`, they may also detect crashes by `shutdown`-record absence: the marker is emitted at the session's own disposal or application teardown, and a marker followed by more events is a telemetry reload. In `FEEDBACK_ONLY`, a released prefix normally has no later `shutdown` marker, so its absence is not a crash signal. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. A resumed local log may contain synthetic closers that were never exported; the wire stream stays faithful to records actually handed to the SDK.
## Model Experience
@@ -40,3 +51,4 @@ None; this package neither assembles nor sends a provider request.
- **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move.
- **Live-collector behavior belongs to the SDK exporter** — authentication, TLS, throttling, and other real OTLP deployment behavior follow the upstream SDK rather than a package-owned compatibility layer.
- **Feedback-time snapshot** — `FEEDBACK_ONLY` retains no telemetry-owned copy before feedback. It reads and redacts the current canonical log when feedback is recorded; a crash before feedback uploads nothing, and policy changes before feedback affect what that replay exports.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
[遥测telemetryseam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。原样组合 OTel JS SDK`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把 seam 交接过来的每条记录映射到 `logger.emit()`并使用两个插桩作用域instrumentation scopeledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份`service.name`/`service.version`来自 `dsh-llm``APP_IDENTITY`,与归因标头同源;另有 `user.id`——本包自有的 harness home 匿名用户 id`src/user-id.ts``$DSH_HOME/.userid`,首用生成随机 UUID删除该文件重置身份),随 Resource 每批导出携带一次而非逐条携带。
[遥测telemetryseam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。`mode` 决定 seam 是实时跟随会话事件、仅在记录反馈时回放权威日志,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`并使用两个插桩作用域instrumentation scopeledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份包含 `service.name`/`service.version`来自 `dsh-llm``APP_IDENTITY`),以及本包的匿名 `user.id``$DSH_HOME/.userid`;首次使用时创建的随机 UUID删除该文件重置);这些身份随每个导出批次携带一次而非逐条记录携带。
## 配置
@@ -10,6 +10,7 @@
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
@@ -18,15 +19,25 @@
processor: {} # optional; passed verbatim to BatchLogRecordProcessor
```
`exporter.url` 是必填项、没有默认值,并且必须能解析为 `http(s)``shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传passthrough`OTLPExporterNodeConfigBase` 的每个字段(`headers``timeoutMillis``compression``keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。但在关闭期间OTel 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的处理器完成 promise如果该传输 promise 始终不结算本包package会在 `shutdownTimeoutMillis` 到期时放弃等待,沿协调器现有的失败隔离路径记录关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。
| `mode` | 行为 |
|---|---|
| `FULL` | 默认值。每条已投影记录都立即交给 OTel SDK包括生命周期运维记录。 |
| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放权威会话日志中截至该事件的后缀,并进行投影与脱敏。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 |
| `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 |
程序化 TypeScript 配置使用导出的 `TelemetryMode` 枚举(`TelemetryMode.FULL``TelemetryMode.FEEDBACK_ONLY``TelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。
上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。
`exporter.url``FULL``FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。在上传模式中,`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传passthrough`OTLPExporterNodeConfigBase` 的每个字段(`headers``timeoutMillis``compression``keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。关闭期间OTel 会先等待 `exporter.forceFlush()`,再进入受处理器 `exportTimeoutMillis` 限制的完成 promise如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待,通过协调器记录已隔离的关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。
## 哪些数据会离开本机
记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall瀑布式事件返回的结果为准用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema`request/header`、todo 文本、压缩compaction摘要、钩子的 `stderrSummary`,以及会话 `cwd`一个本地路径。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。
在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall瀑布式事件返回的结果为准用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema`request/header`、todo 文本、压缩compaction摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`一个本地路径。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。`FULL` 在追加时运行脱敏;`FEEDBACK_ONLY` 不保留遥测副本,而是在反馈触发权威日志回放时运行当时挂载的规则。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。
## 字段映射
seam 记录 → SDK 日志记录:`time``timestamp`/`observedTimestamp``severity``severityNumber`/`severityText`INFO 9 / WARN 13 / ERROR 17`body` → 结构化日志 body`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose资源释放时发出,对于届时仍在运行的会话,则在应用关闭时发出;标记之后出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。跨谱系lineage的流并不自足恢复的会话在其自身 id 的流上从上一个进程停止之处继续fork 出的会话,其流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。继续而非回放的一个后果:流中一个开启后再未关闭的轮次,标志着上一个进程死在了该轮次之内。恢复时本地日志会以合成关闭事件修复,但这些修复绝不导出:导出的流忠实于崩溃进程实际发出的内容,其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出
seam 记录 → SDK 日志记录:`time``timestamp`/`observedTimestamp``severity``severityNumber`/`severityText`INFO 9 / WARN 13 / ERROR 17`body` → 结构化日志 body`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重,并按严重级别告警。在 `FULL` 中,接收端还可通过缺少 `shutdown` 记录检测崩溃:该标记在会话自身 dispose资源释放应用关闭时发出;标记之后出现更多事件,说明遥测发生了重载。在 `FEEDBACK_ONLY` 中,已释放的前缀通常不包含随后的 `shutdown` 标记,因此缺少该标记不是崩溃信号。跨谱系lineage的流并不自足恢复的会话在其自身 id 的流上从上一个进程停止之处继续fork 出的会话流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。恢复后的本地日志可能包含从未导出的合成关闭事件;协议流忠实于实际交给 SDK 的记录
## 模型体验
@@ -40,3 +51,4 @@ seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`
- **上游实验性源码树**`@opentelemetry/sdk-logs` 仍从上游实验性experimental源码树发布SDK API 的变动只会落在本包也仅落在本包seam 契约不动。
- **真实 collector 行为属于 SDK 导出器**身份验证、TLS、限流及其他真实 OTLP 部署行为遵循上游 SDK不由本包自有兼容层处理。
- **反馈时快照**`FEEDBACK_ONLY` 在反馈前不保留遥测自有副本。记录反馈时,它读取并脱敏当前的权威日志;反馈前发生崩溃时什么都不上传,而反馈前的策略变更会影响该次回放的导出内容。

View File

@@ -34,6 +34,7 @@
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-command-feedback": "^0.0.1",
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
@@ -44,6 +45,7 @@
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-command-feedback": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -6,9 +6,9 @@
* record handed over by the seam onto `logger.emit()`. Per the seam's
* boundary axiom, everything downstream of that call (batching, retry,
* queueing, loss policy) is the SDK's documented behavior, configured
* verbatim through the `exporter`/`processor` passthroughs. The one
* backend-owned policy is an outer shutdown deadline: the SDK's export
* timeout does not bound its preceding `forceFlush()` wait.
* verbatim through the `exporter`/`processor` passthroughs. This package owns
* capture mode and an outer shutdown deadline: the SDK's export timeout does
* not bound its preceding `forceFlush()` wait.
*
* @module @deepseek-ai/dsh-session-telemetry-otel
*/
@@ -16,7 +16,14 @@
import { createRequire } from 'node:module'
import z from 'schemastery'
import type { Context } from 'cordis'
import { Telemetry, TelemetryCoordinator, type TelemetryRecord, type TelemetrySeverity } from '@deepseek-ai/dsh-session-telemetry'
import type {} from '@deepseek-ai/dsh-command-feedback'
import {
Telemetry,
TelemetryCoordinator,
type TelemetryBackend,
type TelemetryRecord,
type TelemetrySeverity,
} from '@deepseek-ai/dsh-session-telemetry'
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
import { getOrCreateAnonymousUserId } from './user-id.ts'
import {
@@ -33,12 +40,46 @@ import { resourceFromAttributes } from '@opentelemetry/resources'
// version (same pattern as dsh-llm's attribution identity).
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
/** Session-sharing policy selected by {@link Config.mode}. */
export enum TelemetryMode {
FULL = 'FULL',
FEEDBACK_ONLY = 'FEEDBACK_ONLY',
DISABLED = 'DISABLED',
}
/** Default session-sharing policy for schema and direct construction. */
export const DEFAULT_TELEMETRY_MODE = TelemetryMode.FULL
const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local'
const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log'
const DROP_RECORD: TelemetryBackend['emit'] = () => {}
/** Resolve the default and reject unknown runtime values before transport setup. */
function resolveMode(mode: TelemetryMode | undefined): TelemetryMode {
const resolved = mode ?? DEFAULT_TELEMETRY_MODE
switch (resolved) {
case TelemetryMode.FULL:
case TelemetryMode.FEEDBACK_ONLY:
case TelemetryMode.DISABLED:
return resolved
default:
return assertNever(resolved)
}
}
/** Fail closed when direct construction bypasses the runtime config schema. */
function assertNever(value: never): never {
throw new Error(`session-telemetry-otel: unsupported mode ${JSON.stringify(value)}`)
}
/**
* Plugin configuration: two verbatim SDK option shapes plus one DSH-owned
* shutdown bound. The package validates its endpoint and shutdown deadline
* because both must fail at plugin load rather than at first export or exit.
* Plugin configuration: one sharing policy, two verbatim SDK option shapes,
* and one DSH-owned shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Sharing policy; defaults to immediate `FULL` delivery. */
mode?: TelemetryMode
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
* `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
@@ -46,7 +87,7 @@ export interface Config {
* is the one field this package requires and validates itself.
*/
exporter?: OTLPExporterNodeConfigBase & {
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
url?: string
}
/**
@@ -67,6 +108,7 @@ export interface Config {
* (and silently drop every field not re-declared).
*/
export const Config: z<Config> = z.object({
mode: z.union(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE),
exporter: z.any(),
processor: z.any(),
shutdownTimeoutMillis: z.number(),
@@ -87,23 +129,32 @@ const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; seve
}
/**
* The backend plugin — the only entry a deployment loads. Constructing it
* wires the SDK pipeline, registers the `telemetry` service (duplicate load
* throws, cordis' standard duplicate-service behavior), and composes the
* seam's {@link TelemetryCoordinator}, which installs the capture side onto
* this fiber.
* The backend plugin — the only entry a deployment loads. It always registers
* the `telemetry` service (duplicate load throws). Uploading modes wire the SDK
* pipeline and compose {@link TelemetryCoordinator}; `DISABLED` constructs no
* SDK state and listens only to warn when recorded feedback stays local.
*/
export class TelemetryOtel extends Telemetry {
static inject = ['sessions']
static Config = Config
private readonly provider: LoggerProvider
private readonly ledger: Logger
private readonly ops: Logger
private readonly directEmit: TelemetryBackend['emit']
private readonly provider: LoggerProvider | undefined
private readonly shutdownTimeoutMillis: number
constructor(ctx: Context, config: Config) {
const mode = resolveMode(config.mode)
super(ctx)
if (mode === TelemetryMode.DISABLED) {
this.directEmit = DROP_RECORD
this.provider = undefined
this.shutdownTimeoutMillis = DEFAULT_SHUTDOWN_TIMEOUT_MILLIS
ctx.on('session/event', (_session, event) => {
if (event.type === 'feedback/record') ctx.logger.warn(DISABLED_FEEDBACK_WARNING)
})
return
}
const url = config.exporter?.url
if (url === undefined || url.length === 0) {
throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)')
@@ -153,27 +204,50 @@ export class TelemetryOtel extends Telemetry {
}),
],
})
this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version)
this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version)
new TelemetryCoordinator(ctx, this)
const ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version)
const ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version)
const enqueue: TelemetryBackend['emit'] = (record) => {
const logger: Logger = record.channel === 'ops' ? ops : ledger
logger.emit({
timestamp: record.time,
observedTimestamp: record.time,
...SEVERITY[record.severity],
// JSON-serializable by the seam's contract (validated at Session.append),
// which is exactly the AnyValue subset.
body: record.body as AnyValue,
attributes: record.attributes,
})
}
const backend: TelemetryBackend = {
emit: enqueue,
shutdown: () => this.shutdown(),
}
if (mode === TelemetryMode.FULL) {
this.directEmit = enqueue
new TelemetryCoordinator(ctx, backend, 'live')
return
}
this.directEmit = DROP_RECORD
const coordinator = new TelemetryCoordinator(ctx, backend, 'on-demand')
ctx.on('session/event', (session, event) => {
if (event.type !== 'feedback/record') return
// Consent is the committed record, not an independently emitted bus value.
if (session.events[event.seq] !== event) {
ctx.logger.warn(NON_CANONICAL_FEEDBACK_WARNING)
return
}
coordinator.captureSession(session, event.seq)
})
}
/**
* Map one seam record onto the SDK logger for its channel — a synchronous
* enqueue into the batch processor's queue.
* @param record - the logical record handed over by the coordinator.
* Hand a direct service record to the SDK only in `FULL`. Direct calls are
* no-ops in `FEEDBACK_ONLY` and `DISABLED`; feedback replay uses a private
* backend capability created only for the canonical feedback listener.
* @param record - the logical record offered directly to the service.
*/
emit(record: TelemetryRecord): void {
const logger = record.channel === 'ops' ? this.ops : this.ledger
logger.emit({
timestamp: record.time,
observedTimestamp: record.time,
...SEVERITY[record.severity],
// JSON-serializable by the seam's contract (validated at Session.append),
// which is exactly the AnyValue subset.
body: record.body as AnyValue,
attributes: record.attributes,
})
this.directEmit(record)
}
// The seam's optional flush() hint is deliberately NOT implemented. The
@@ -191,9 +265,11 @@ export class TelemetryOtel extends Telemetry {
* shutdown awaits `exporter.forceFlush()` first, which can remain pending
* when the transport never obtains a socket. The provider promise remains
* observed after the deadline so a later rejection cannot become unhandled.
* @returns resolves when the SDK pipeline quiesces, or rejects at the configured deadline.
* `DISABLED` has no provider and resolves immediately.
* @returns resolves when the SDK pipeline quiesces or is disabled, or rejects at the configured deadline.
*/
async shutdown(): Promise<void> {
if (this.provider === undefined) return
const providerShutdown = this.provider.shutdown()
let timer: ReturnType<typeof setTimeout> | undefined
const deadline = new Promise<never>((_resolve, reject) => {

View File

@@ -15,10 +15,9 @@ export const name = 'session-telemetry-otel-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the backend forwards seam records into the OTel SDK's
* in-process pipeline and appends nothing to any session; its only observable
* effects (batching, export) happen inside the SDK past the seam's boundary
* axiom, out of reach of an independent companion.
* No runtime invariant: mode selection changes capture handoff, SDK setup, and
* local diagnostics without mutating session or service state an independent
* companion can compare. Export remains inside the SDK past the seam boundary.
*/
const install: InvariantInstaller = () => {}

View File

@@ -40,6 +40,11 @@ interface OtlpCapture {
}[]
}
interface FixtureOutput {
captures: OtlpCapture[]
logContent: string
}
async function jsonlFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths = await Promise.all(entries.map(async (entry) => {
@@ -50,10 +55,29 @@ async function jsonlFiles(dir: string): Promise<string[]> {
return paths.flat()
}
async function readFixtureOutput(cwd: string): Promise<FixtureOutput> {
const captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[]
const logs = await jsonlFiles(join(cwd, '.sessions'))
expect(logs).toHaveLength(1)
return { captures, logContent: await readFile(logs[0] as string, 'utf8') }
}
function allRecords(captures: OtlpCapture[]) {
return captures.flatMap(capture => capture.resourceLogs.flatMap(resource =>
resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record })))))
}
function eventTypes(captures: OtlpCapture[]): string[] {
return allRecords(captures).flatMap(({ record }) =>
record.attributes?.flatMap(attribute =>
attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string'
? [attribute.value['stringValue']]
: []) ?? [])
}
describe('session-telemetry-otel through a real headless cordis.yml', () => {
it('exports redacted ledger records to the collector while the canonical log keeps the secret', async () => {
let captures: OtlpCapture[] = []
let logContent = ''
let output!: FixtureOutput
const { stderr } = await runLoaderSmoke({
label: 'session-telemetry-otel loader smoke',
tempDirPrefix: 'telemetry-otel-e2e-',
@@ -61,39 +85,70 @@ describe('session-telemetry-otel through a real headless cordis.yml', () => {
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
inspect: async (cwd) => {
captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[]
const logs = await jsonlFiles(join(cwd, '.sessions'))
expect(logs).toHaveLength(1)
logContent = await readFile(logs[0] as string, 'utf8')
},
inspect: async (cwd) => { output = await readFixtureOutput(cwd) },
})
expect(stderr).not.toContain('UNHANDLED')
const records = captures.flatMap(capture => capture.resourceLogs.flatMap(resource =>
resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record })))))
const records = allRecords(output.captures)
expect(records.length).toBeGreaterThan(0)
const eventTypes = records.flatMap(({ record }) =>
record.attributes?.flatMap(attribute =>
attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string'
? [attribute.value['stringValue']]
: []) ?? [])
const types = eventTypes(output.captures)
for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) {
expect(eventTypes, expected).toContain(expected)
expect(types, expected).toContain(expected)
}
expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true)
// The deployment-mounted rule on the wire: the fixture credential never
// leaves the process, its surrounding prose does, and the placeholder
// marks the spot — the seam itself ships no rules.
const wire = JSON.stringify(captures)
const wire = JSON.stringify(output.captures)
expect(wire).not.toContain(FIXTURE_SECRET)
expect(wire).toContain(FIXTURE_PLACEHOLDER)
expect(wire).toContain('prove telemetry with key')
// The canonical session log is never rewritten.
expect(logContent).toContain(FIXTURE_SECRET)
expect(logContent).not.toContain(FIXTURE_PLACEHOLDER)
expect(output.logContent).toContain(FIXTURE_SECRET)
expect(output.logContent).not.toContain(FIXTURE_PLACEHOLDER)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('exports only prefixes ending in feedback under feedback-only mode', async () => {
let output!: FixtureOutput
const { stderr } = await runLoaderSmoke({
label: 'session-telemetry-otel feedback-only loader smoke',
tempDirPrefix: 'telemetry-otel-feedback-e2e-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
env: { DSH_TELEMETRY_E2E_MODE: 'FEEDBACK_ONLY' },
inspect: async (cwd) => { output = await readFixtureOutput(cwd) },
})
expect(stderr).not.toContain('UNHANDLED')
const wire = JSON.stringify(output.captures)
expect(eventTypes(output.captures)).toContain('feedback/record')
expect(wire).toContain('fixture feedback')
expect(wire).toContain('prove telemetry with key')
expect(wire).not.toContain('post-feedback private suffix')
expect(output.logContent).toContain('post-feedback private suffix')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('keeps disabled feedback local and prints the stable warning', async () => {
let output!: FixtureOutput
const { stdout } = await runLoaderSmoke({
label: 'session-telemetry-otel disabled loader smoke',
tempDirPrefix: 'telemetry-otel-disabled-e2e-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
env: { DSH_TELEMETRY_E2E_MODE: 'DISABLED' },
inspect: async (cwd) => { output = await readFixtureOutput(cwd) },
})
expect(output.captures).toEqual([])
expect(output.logContent).toContain('fixture feedback')
expect(stdout.match(/session telemetry is DISABLED; nothing will be shared and this feedback remains local/)?.[0])
.toMatchInlineSnapshot('"session telemetry is DISABLED; nothing will be shared and this feedback remains local"')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -5,7 +5,7 @@
* for the default-exported Service class.
*/
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import { afterAll, afterEach, beforeAll, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { createServer, type Server } from 'node:http'
import { once } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
@@ -15,8 +15,9 @@ import { gunzipSync } from 'node:zlib'
import { Context } from 'cordis'
import { getOrCreateAnonymousUserId } from '../src/user-id.ts'
import Loader from '@cordisjs/plugin-loader'
import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import TelemetryOtel, { Config } from '../src/index.ts'
import TelemetryOtel, { Config, DEFAULT_TELEMETRY_MODE, TelemetryMode } from '../src/index.ts'
interface Capture {
headers: import('node:http').IncomingHttpHeaders
@@ -34,6 +35,7 @@ interface OtlpLogsRequest {
severityNumber: number
severityText: string
attributes?: { key: string; value: Record<string, unknown> }[]
body?: unknown
}[]
}[]
}[]
@@ -107,6 +109,14 @@ function allRecords(captures: Capture[]) {
s.logRecords.map(record => ({ scope: s.scope.name, record })))))
}
function eventTypes(captures: Capture[]): string[] {
return allRecords(captures).flatMap(({ record }) =>
record.attributes?.flatMap(attribute =>
attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string'
? [attribute.value['stringValue']]
: []) ?? [])
}
describe('TelemetryOtel wire', () => {
it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => {
const { url, captures } = await mockCollector()
@@ -114,6 +124,13 @@ describe('TelemetryOtel wire', () => {
const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
ctx.telemetry.emit({
channel: 'ledger',
time: Date.now(),
severity: 'info',
attributes: { 'session.id': 'wire', 'event.type': 'manual', 'event.seq': 99 },
body: { direct: true },
})
await fiber.dispose()
expect(captures.length).toBeGreaterThan(0)
@@ -138,6 +155,7 @@ describe('TelemetryOtel wire', () => {
const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end'))
expect(end?.record.severityNumber).toBe(17)
expect(end?.record.severityText).toBe('ERROR')
expect(eventTypes(captures)).toContain('manual')
expect(ops).toHaveLength(1)
expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
@@ -247,14 +265,134 @@ describe('TelemetryOtel wire', () => {
r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
expect(start?.record.severityNumber).toBe(13)
})
it('replays each session suffix only at the next feedback event', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FEEDBACK_ONLY,
exporter: { url },
})
ctx.on('telemetry/record', (_record, next) => {
ctx.telemetry.emit({
channel: 'ledger',
time: Date.now(),
severity: 'info',
attributes: { 'session.id': 'feedback-only', 'event.type': 'direct-bypass', 'event.seq': 99 },
body: { mustStayLocal: true },
})
return next()
})
const session = ctx.sessions.create(SessionId('feedback-only'), { meta: {} })
session.append('turn/start', { turn: 1 })
recordFeedback(session, 'first report')
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
recordFeedback(session, 'second report')
session.append('turn/start', { turn: 2 })
await fiber.dispose()
const types = allRecords(captures).flatMap(({ record }) =>
record.attributes?.flatMap(attribute =>
attribute.key === 'event.type' ? [attribute.value.stringValue] : []) ?? [])
expect(types).toEqual(['turn/start', 'feedback/record', 'turn/end', 'feedback/record'])
expect(JSON.stringify(captures)).toContain('first report')
expect(JSON.stringify(captures)).toContain('second report')
expect(allRecords(captures).some(({ scope }) => scope.endsWith('/ops'))).toBe(false)
})
it('ignores direct emits and non-canonical feedback in feedback-only mode', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FEEDBACK_ONLY,
exporter: { url },
})
const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} })
session.append('turn/start', { turn: 1 })
ctx.telemetry.emit({
channel: 'ledger',
time: Date.now(),
severity: 'info',
attributes: { 'session.id': 'no-feedback', 'event.type': 'direct', 'event.seq': 99 },
body: { mustStayLocal: true },
})
ctx.emit('session/event', session, {
type: 'feedback/record',
seq: session.events.length,
time: Date.now(),
data: { text: 'not committed' },
})
await fiber.dispose()
expect(warn).toHaveBeenCalledWith(
'session telemetry ignored a feedback event absent from the canonical session log',
)
expect(captures).toEqual([])
})
it('constructs no disabled transport even when exporter options are present', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.DISABLED,
exporter: { url },
processor: { maxExportBatchSize: 0 },
})
const session = ctx.sessions.create(SessionId('disabled'), { meta: {} })
session.append('turn/start', { turn: 1 })
recordFeedback(session, 'local report')
expect(warn).toHaveBeenCalledWith(
'session telemetry is DISABLED; nothing will be shared and this feedback remains local',
)
ctx.telemetry.emit({
channel: 'ledger',
time: 0,
severity: 'info',
attributes: {},
body: null,
})
await ctx.telemetry.shutdown()
await fiber.dispose()
recordFeedback(session, 'after disposal')
expect(warn).toHaveBeenCalledTimes(1)
expect(captures).toEqual([])
})
it('defaults direct construction to full delivery', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
new TelemetryOtel(ctx, { exporter: { url } })
const session = ctx.sessions.create(SessionId('direct-default'), { meta: {} })
session.append('turn/start', { turn: 1 })
await ctx.fiber.dispose()
expect(eventTypes(captures)).toContain('turn/start')
})
})
describe('TelemetryOtel config fails loud', () => {
it('exposes modes through the nominal enum', () => {
expectTypeOf<Config['mode']>().toEqualTypeOf<TelemetryMode | undefined>()
expectTypeOf<'FULL'>().not.toExtend<TelemetryMode>()
expectTypeOf<TelemetryMode.FULL>().toExtend<TelemetryMode>()
expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.FULL)
expect(Config({}).mode).toBe(DEFAULT_TELEMETRY_MODE)
})
it.each([
[{}, /exporter\.url is required/],
[{ exporter: { url: '' } }, /exporter\.url is required/],
[{ exporter: { url: 'not a url' } }, /not a valid URL/],
[{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
[{ mode: TelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/],
[{ mode: 'INVALID' }, /INVALID/],
// The SDK accepts a non-positive batch size but its shutdown drain then
// splices empty batches forever — dispose would hang, so reject at load.
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
@@ -266,6 +404,46 @@ describe('TelemetryOtel config fails loud', () => {
await ctx.plugin(SessionStore)
await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message)
})
it('rejects an unknown direct mode before reading transport config', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
let exporterRead = false
const config = {
mode: 'INVALID',
get exporter() {
exporterRead = true
throw new Error('transport config was read')
},
} as unknown as Config
expect(() => new TelemetryOtel(ctx, config)).toThrow(/unsupported mode "INVALID"/)
expect(exporterRead).toBe(false)
})
it('does not read any transport setting in disabled mode', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const transportRead = vi.fn(() => {
throw new Error('transport config was read')
})
const config = {
mode: TelemetryMode.DISABLED,
get exporter() {
return transportRead()
},
get processor() {
return transportRead()
},
get shutdownTimeoutMillis() {
return transportRead()
},
} as unknown as Config
new TelemetryOtel(ctx, config)
expect(transportRead).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
})
describe('dsh-session-telemetry-otel real-load-path guard', () => {

View File

@@ -20,6 +20,9 @@
{
"path": "../../core/session"
},
{
"path": "../../feedback/command-feedback"
},
{
"path": "../../llm/llm"
},

View File

@@ -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/telemetry/session-telemetry/README.md
README.md: 272c9abe78849be3d2bba2c54cd7e25bcbe2d4c2
README.zh.md: 6a72389135b3f4009625f7448f2774f239b804b5
README.md: 67d95bcc62bbf6783f8dcd11f0236d8c926b557b
README.zh.md: 1ee0e0eb14bb06c8ac669cd417f2ee2ce46ca430

View File

@@ -2,23 +2,23 @@
English | [中文](README.zh.md)
The telemetry seam: the CAPTURE side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
The telemetry seam: the capture side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. Capture can follow live session events or replay a canonical session-log prefix on demand. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md).
## The backend contract
`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor.
`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path or during an explicit canonical-log replay), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `live` capture or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its owning trigger.
## Capture points
The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`).
In `live` mode the coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). In `on-demand` mode it registers only the dispose effect: `captureSession()` reads the canonical log through an optional inclusive sequence boundary, while flush hints and operational events remain local.
## The redact waterfall
Every record passes the `telemetry/record` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten.
Every record passes the `telemetry/record` waterfall immediately after projection — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Live capture runs the waterfall at append time; on-demand capture runs it while replaying the canonical log, using the rules mounted at that time. Redaction applies to the outbound copy only; the canonical session log is never rewritten.
## The handoff cursor
A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error.
A module-scope `WeakMap<Session, seq>` marks the highest seq HANDED OFF (not delivered) per session. Live capture advances it at append time; on-demand capture advances it only while `captureSession()` hands a requested prefix to the backend. An uncaptured prefix remains solely in the canonical log, so a coordinator reload adds no telemetry-owned recovery state. On replay the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error.
## The fixed chunk projection
@@ -40,3 +40,4 @@ None; this package neither assembles nor sends a provider request.
- **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
- **No built-in redaction rules** — with no `telemetry/record` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set.
- **On-demand redaction uses current state** — uncaptured events exist only in the canonical session log. A later `captureSession()` deep-copies and redacts their current values with the policy mounted at that time; there is no capture-time telemetry snapshot or durable pre-capture spool.

View File

@@ -2,23 +2,23 @@
[English](README.md) | 中文
遥测telemetryseam会话事件上报的捕获侧隔在一个后端契约之后任何上报 SDK 都无需变形即可满足该契约。塑造本包一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK本包既不为其立规也不做包装。设计依据与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
遥测telemetryseam会话事件上报的捕获侧隔在一个后端契约之后任何上报 SDK 都无需变形即可满足该契约。捕获侧可跟随实时会话事件也可按需回放权威会话日志前缀。塑造本包package一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK本包既不为其立规也不做包装。设计依据与被否决的替代方案见[复活 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)
## 后端契约
`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose资源释放时被等待`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端在其构造函数中组合 `TelemetryCoordinator`
`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径或显式权威日志回放期间同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose资源释放时被等待`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端`live``on-demand` 模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `captureSession(session, throughSeq?)`
## 捕获点
协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header并经投影从构造边界起回读日志来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏交接;零 I/O`session/flush`(转发可选的 `flush()` 提示并返回 void循环所等待的并行任务绝不能等待遥测`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect拆卸时先标记每个仍存活会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。
`live` 模式中,协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header并经投影从构造边界起回读日志来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接;零 I/O`session/flush`(转发可选的 `flush()` 提示并返回 void循环所等待的并行任务绝不能等待遥测`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。`on-demand` 模式中,协调器只注册 dispose effect`captureSession()` 读取权威日志直至可选的序列号边界含边界flush 提示与运维事件留在本地。
## 脱敏 waterfall瀑布式事件
每条记录在投影`emit()` 之间都要经过 `telemetry/record` waterfall这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。脱敏只作用于导出副本;权威会话日志永不改写。
每条记录在投影后立即经过 `telemetry/record` waterfall这是该 seam 的脱敏扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式拦下这一条记录。实时捕获在追加时运行 waterfall按需捕获则在回放权威日志时使用当时挂载的规则运行 waterfall。脱敏只作用于外发副本;权威会话日志永不改写。
## handoff 游标
一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接。由此接受的代价与至多一次at-most-once投递一致恢复不会回填上一个进程未能投递的记录有回填要求的部署需要的是已推迟的 outbox而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外条目随其会话消亡值是单调水位线丢失它绝不是错误。
一个模块作用域的 `WeakMap<Session, seq>` 记录每个会话已交接(而非已投递)的最高 seq。实时捕获在追加时推进游标;按需捕获只有在 `captureSession()` 将请求的前缀交给后端时才推进游标。未捕获的前缀只留在权威日志中,因此协调器重载不会增加遥测自有的恢复状态。回放时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接。由此接受的代价与至多一次at-most-once投递一致恢复不会回填上一个进程未能投递的记录有回填要求的部署需要的是已推迟的 outbox而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外条目随其会话消亡值是单调水位线丢失它绝不是错误。
## 固定分片投影
@@ -40,3 +40,4 @@
- **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久化 outboxspool、每 sink 游标、at-least-once推迟到有部署方提出明确的崩溃丢失要求时再实现见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
- **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。
- **按需脱敏使用当前状态**:未捕获的事件只存在于权威会话日志中。后续的 `captureSession()` 会使用当时挂载的策略,深拷贝并脱敏其当前值;不存在捕获时的遥测快照或持久化的捕获前 spool。

View File

@@ -1,12 +1,15 @@
/**
* Capture coordinator: the seam's upstream half. Subscribes to the session
* firehose plus the one live-bus relay (`agent/error`), applies the fixed
* chunk projection, builds logical records, runs each through the
* Capture coordinator: the seam's upstream half. Live capture subscribes to
* the session firehose plus the one live-bus relay (`agent/error`). Both
* capture paths apply the fixed chunk projection, build logical records, and
* run each through the
* `telemetry/record` waterfall (deployment-mounted redaction rules;
* pass-through when none), and hands the result to the backend — synchronously, with every
* handler self-contained so a failing backend can never starve other
* subscribers (cordis `emit` is stop-on-throw) or touch the agent loop.
* Composed by a backend in its constructor.
* pass-through when none), then hands the result to the backend. Live capture
* follows the session firehose; on-demand capture replays the canonical log
* only when requested. Every synchronous handler is self-contained so a
* failing backend can never starve other subscribers (cordis `emit` is
* stop-on-throw) or touch the agent loop. Composed by a backend in its
* constructor.
*
* @module @deepseek-ai/dsh-session-telemetry/coordinator
*/
@@ -16,6 +19,16 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts'
/** Whether capture follows live events or reads the canonical log only when requested. */
export type TelemetryCapture = 'live' | 'on-demand'
/** One projected record ready for backend handoff. */
interface ProjectedRecord {
readonly record: TelemetryRecord
/** Ledger cursor advanced only after the backend accepts this record. */
readonly seq?: number
}
/**
* The handoff cursor: per session, the highest `seq` handed to a backend.
* Deliberately MODULE-scope ambient state — a narrow, documented exception
@@ -32,17 +45,17 @@ const handoffCursor = new WeakMap<Session, number>()
/**
* Install the telemetry capture side onto a context for one backend.
*
* Registers the persistence-coordinator listener set plus the `agent/error`
* relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and
* sweeps already-live sessions (a hot reload does not replay
* `session/created`). A `session/disposed` emits the session's `shutdown`
* operational record — the marker rides the session's own termination edge,
* where receivers key crash detection — and retires it from the adopted set,
* so a long-lived backend neither retains closed sessions (and their frozen
* event logs) nor re-marks them at unload. Disposal marks the sessions still
* alive at teardown (their own edge would fire unobserved) and then awaits
* the backend's `shutdown()`; a failure there warns instead of throwing —
* best-effort reporting must not fail application teardown.
* Live capture registers the persistence-coordinator listener set plus the
* `agent/error` relay, all through `ctx.effect()`/`ctx.on()` on the composing
* fiber, and sweeps already-live sessions (a hot reload does not replay
* `session/created`). A `session/disposed` captures the session's `shutdown`
* operational record at its own termination edge and retires it from the
* adopted set. On-demand capture registers none of those continuous listeners;
* {@link captureSession} reads the canonical log explicitly and never creates
* operational records. Disposal captures shutdown markers for live-adopted
* sessions, then awaits the backend's `shutdown()`; a failure there warns
* instead of throwing — best-effort reporting must not fail application
* teardown.
*/
export class TelemetryCoordinator {
/**
@@ -53,55 +66,55 @@ export class TelemetryCoordinator {
private readonly adopted = new Set<Session>()
/** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */
private readonly chunkSeen = new WeakMap<Session, Set<string>>()
/**
* @param ctx - the composing backend's context; listeners bind to its fiber.
* @param backend - the backend receiving records; owned elsewhere, never disposed here beyond `shutdown()` forwarding.
* @param capture - follow live events, or wait for explicit canonical-log capture.
*/
constructor(
private readonly ctx: Context,
private readonly backend: TelemetryBackend,
capture: TelemetryCapture = 'live',
) {
ctx.on('session/created', (session) => {
this.adopt(session)
})
// The session's own termination edge: emit the shutdown marker HERE —
// receivers classify a session with activity and no marker as crashed,
// so a normally closed session in a long-running host must get its
// marker at disposal, not never. Then retire: the projection/cursor
// WeakMaps die with the Session object; only the strong adopted set
// needs the explicit release.
ctx.on('session/disposed', (session) => {
this.contain(() => {
if (!this.adopted.delete(session)) return
this.handOff(shutdownRecord(session))
if (capture === 'live') {
ctx.on('session/created', (session) => {
this.adopt(session)
})
})
ctx.on('session/event', (session, event) => {
this.contain(() => {
this.capture(session, event)
// Capture the shutdown marker at the session's own termination edge,
// then retire the only strong reference owned by this coordinator.
ctx.on('session/disposed', (session) => {
this.contain(() => {
if (!this.adopted.delete(session)) return
this.deliver(session, { record: this.redact(shutdownRecord(session)) })
})
})
})
// Parallel listeners are awaited by the loop at turn end; returning void
// (not the SDK's flush promise) is the turn-latency contract.
ctx.on('session/flush', (session) => {
this.contain(() => {
this.hintFlush(session)
ctx.on('session/event', (session, event) => {
this.contain(() => {
this.captureEvent(session, event)
})
})
})
ctx.on('agent/error', ({ agent, turn, step, error }) => {
this.contain(() => {
this.relayAgentError(agent, turn, step, error)
// Parallel listeners are awaited by the loop at turn end; returning void
// (not the SDK's flush promise) is the turn-latency contract.
ctx.on('session/flush', (session) => {
this.contain(() => {
this.hintFlush(session)
})
})
})
ctx.on('agent/error', ({ agent, turn, step, error }) => {
this.contain(() => {
this.relayAgentError(agent, turn, step, error)
})
})
for (const session of ctx.sessions.list()) {
this.adopt(session)
}
}
ctx.effect(() => async () => {
// Sessions still adopted here are alive through a whole-application
// teardown (their own disposal edge will fire after telemetry is gone,
// unobserved) — mark them now so the receiver sees a clean stop of
// observation rather than a crash-shaped silence.
// Sessions still adopted here are alive through whole-application
// teardown, so capture the marker before the backend quiesces.
for (const session of this.adopted) {
this.contain(() => {
this.handOff(shutdownRecord(session))
this.deliver(session, { record: this.redact(shutdownRecord(session)) })
})
}
try {
@@ -110,8 +123,28 @@ export class TelemetryCoordinator {
this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`)
}
}, 'telemetry capture')
for (const session of ctx.sessions.list()) {
this.adopt(session)
}
/**
* Project and hand over the canonical session-log suffix after the handoff
* cursor, optionally stopping at an inclusive sequence boundary. Redaction
* runs during this call, so an on-demand caller retains no copied records
* before requesting capture and uses the policy mounted at that time.
* Backend and policy failures remain contained per event and do not starve
* later events in the same replay.
* @param session - session whose current canonical-log prefix may be handed over.
* @param throughSeq - optional last sequence included in this capture.
*/
captureSession(session: Session, throughSeq?: number): void {
const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1
// Containment is PER EVENT: one rejected record is withheld fail-closed
// while the rest of the historical replay proceeds.
for (const event of session.events) {
if (throughSeq !== undefined && event.seq > throughSeq) break
this.contain(() => {
if (event.seq <= cursor) this.track(session, event)
else this.captureEvent(session, event)
})
}
}
@@ -133,17 +166,7 @@ export class TelemetryCoordinator {
private adopt(session: Session): void {
if (this.adopted.has(session)) return
this.adopted.add(session)
const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1
// Containment is PER EVENT, matching the firehose: one rejected record
// is withheld fail-closed while the rest of the historical replay
// proceeds — wrapping the whole loop would let a single failure silently
// skip the remainder of the log on an already-adopted session.
for (const event of session.events) {
this.contain(() => {
if (event.seq <= cursor) this.track(session, event)
else this.capture(session, event)
})
}
this.captureSession(session)
}
/** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */
@@ -153,8 +176,8 @@ export class TelemetryCoordinator {
}
}
/** Project one event and hand it to the backend, advancing the cursor on handoff. */
private capture(session: Session, event: SessionEvent): void {
/** Project, redact, and hand one event to the backend. */
private captureEvent(session: Session, event: SessionEvent): void {
if (event.type === 'assistant/chunk') {
const key = `${event.data.turn}:${event.data.step}`
const seen = this.seen(session)
@@ -165,27 +188,36 @@ export class TelemetryCoordinator {
if (seen.has(key)) return
seen.add(key)
}
this.handOff({
channel: 'ledger',
time: event.time,
severity: severityOf(event),
attributes: identityOf(session, event),
// The live event object is mutable and the backend serializes later;
// append-time validation guarantees this clone cannot throw.
body: structuredClone(event.data),
this.deliver(session, {
record: this.redact({
channel: 'ledger',
time: event.time,
severity: severityOf(event),
attributes: identityOf(session, event),
// The canonical event object is mutable and the backend serializes
// later; append-time validation guarantees this clone cannot throw.
body: structuredClone(event.data),
}),
seq: event.seq,
})
handoffCursor.set(session, event.seq)
}
/**
* Run the `telemetry/record` waterfall over one record and hand the result
* to the backend. The innermost `next` passes the record through unchanged
* — the seam ships no rules; exported data is as clean as the listeners a
* deployment mounts. Callers run inside {@link contain}, so a throwing
* rule withholds the record instead of reaching the loop (fail-closed).
* Run the `telemetry/record` waterfall at capture time. The innermost `next`
* passes the record through unchanged — the seam ships no rules; exported
* data is as clean as the listeners a deployment mounts. Callers run inside
* {@link contain}, so a throwing rule withholds the record instead of
* reaching the loop (fail-closed). On-demand capture invokes this waterfall
* while reading the canonical session log, not when the event was appended.
*/
private handOff(record: TelemetryRecord): void {
this.backend.emit(this.ctx.waterfall('telemetry/record', record, () => record))
private redact(record: TelemetryRecord): TelemetryRecord {
return this.ctx.waterfall('telemetry/record', record, () => record)
}
/** Hand one redacted record to the backend, then advance its ledger cursor. */
private deliver(session: Session, pending: ProjectedRecord): void {
this.backend.emit(pending.record)
if (pending.seq !== undefined) handoffCursor.set(session, pending.seq)
}
/** Forward the turn-end boundary to the backend's optional flush hint. */
@@ -196,19 +228,21 @@ export class TelemetryCoordinator {
/** Relay one `agent/error` bus emission as an `agent-error` operational record. */
private relayAgentError(agent: Agent, turn: number, step: number, error: unknown): void {
const detail = errorDetail(error)
this.handOff({
channel: 'ops',
time: Date.now(),
severity: 'error',
attributes: {
'telemetry.op': 'agent-error',
'session.id': String(agent.session.id),
'agent.id': agent.id,
'error.name': detail.name,
turn,
step,
},
body: detail,
this.deliver(agent.session, {
record: this.redact({
channel: 'ops',
time: Date.now(),
severity: 'error',
attributes: {
'telemetry.op': 'agent-error',
'session.id': String(agent.session.id),
'agent.id': agent.id,
'error.name': detail.name,
turn,
step,
},
body: detail,
}),
})
}

View File

@@ -3,9 +3,10 @@
*
* The seam owns the CAPTURE side of session-event reporting — which records
* exist (the chunk projection), what they carry (the logical record), when
* they are handed over (adoption, the per-append firehose, lifecycle
* forwarding), and the HMR handoff cursor. Everything downstream of
* {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the
* they are captured (adoption, the per-append firehose, lifecycle
* forwarding), live versus on-demand canonical-log capture, and the HMR
* cursor. Everything downstream of
* {@link Telemetry.emit} — batching, retry, queueing, and loss policy — is the
* reporting SDK's territory and is deliberately not modelled here. The
* design and its trade-offs are pinned in
* .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md.
@@ -32,8 +33,9 @@ declare module 'cordis' {
* `next()` replaces everything beneath. Dispatched synchronously on the
* capture hot path inside the coordinator's containment: a throwing
* listener withholds that one record (fail-closed) and never reaches the
* agent loop. Redaction applies to the exported copy only; the canonical
* session log is never rewritten.
* agent loop. Live capture dispatches at append time; on-demand capture
* dispatches while reading the canonical log. Redaction applies to the
* exported copy only; the canonical session log is never rewritten.
* @param record - the candidate record, already the coordinator's own deep
* copy; listeners return a (possibly new) record and must not mutate it.
* @mode waterfall
@@ -94,9 +96,10 @@ export interface TelemetryBackend {
/**
* Hand one record to the backend's pipeline. MUST be a non-blocking
* enqueue — the coordinator calls this synchronously from the
* `session/event` hot path, so anything slower than a queue push would tax
* the agent loop. Errors thrown here are contained by the coordinator and
* logged; they never reach the loop.
* `session/event` hot path or an explicit canonical-log capture, so anything
* slower than a queue push would tax the agent loop or feedback handling.
* Errors thrown here are contained by the coordinator and logged; they
* never reach the loop.
* @param record - the logical record to report; owned by the backend after the call.
*/
emit(record: TelemetryRecord): void
@@ -121,6 +124,8 @@ export interface TelemetryBackend {
* coordinator emits its dispose-time `shutdown` markers immediately before
* calling this). Awaited by the coordinator's dispose; a rejection is
* logged as a warning and never fails application teardown.
* The coordinator captures dispose-time shutdown markers immediately before
* this call for live capture; on-demand capture creates no ops records.
* @returns resolves when the backend's pipeline has quiesced.
*/
shutdown(): Promise<void>
@@ -153,4 +158,4 @@ export abstract class Telemetry extends Service implements TelemetryBackend {
abstract shutdown(): Promise<void>
}
export { TelemetryCoordinator } from './coordinator.ts'
export { TelemetryCoordinator, type TelemetryCapture } from './coordinator.ts'

View File

@@ -10,7 +10,12 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord } from '../src/index.ts'
import {
TelemetryCoordinator,
type TelemetryBackend,
type TelemetryCapture,
type TelemetryRecord,
} from '../src/index.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
@@ -54,15 +59,21 @@ class FakeBackend implements TelemetryBackend {
}
}
async function setup(backend: FakeBackend = new FakeBackend()) {
async function setup(
backend: FakeBackend = new FakeBackend(),
capture: TelemetryCapture = 'live',
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
let coordinator!: TelemetryCoordinator
const fiber = await ctx.plugin({
name: 'fake-telemetry',
inject: ['sessions'],
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
apply: (inner: Context) => {
coordinator = new TelemetryCoordinator(inner, backend, capture)
},
})
return { ctx, backend, fiber }
return { ctx, backend, coordinator, fiber }
}
function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session {
@@ -167,6 +178,104 @@ describe('TelemetryCoordinator capture', () => {
})
})
describe('TelemetryCoordinator on-demand capture', () => {
it('captures one canonical-log prefix at a time without following later events', async () => {
const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand')
const session = liveSession(ctx, 'on-demand-prefix')
appendTurn(session)
const firstBoundary = session.events[1]!.seq
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(backend.records).toEqual([])
coordinator.captureSession(session, firstBoundary)
expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([
'turn/start',
'user/message',
])
expect(backend.ledger()).toHaveLength(2)
coordinator.captureSession(session)
coordinator.captureSession(session)
expect(backend.ledger().map(record => record.attributes['event.type'])).toEqual([
'turn/start',
'user/message',
'turn/end',
])
})
it('runs the currently mounted redaction policy during canonical-log capture', async () => {
const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand')
const session = liveSession(ctx, 'on-demand-redacted')
session.append('turn/start', { turn: 1 })
const disposeRule = ctx.on('telemetry/record', (_record, next) => ({
...next(),
body: { scrubbed: true },
}))
coordinator.captureSession(session)
expect(backend.ledger()[0]!.body).toEqual({ scrubbed: true })
disposeRule()
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
coordinator.captureSession(session)
expect(backend.ledger()[1]!.body).toEqual({ turn: 1, reason: { kind: 'completed' } })
})
it('contains each backend failure independently while replaying a prefix', async () => {
const backend = new FakeBackend()
backend.rejectSeq = 1
const { ctx, coordinator } = await setup(backend, 'on-demand')
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const session = liveSession(ctx, 'on-demand-failure')
appendTurn(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
coordinator.captureSession(session)
expect(backend.ledger().map(record => record.attributes['event.seq'])).toEqual([0, 2])
expect(warn).toHaveBeenCalled()
})
it('captures a pending prefix after coordinator reload without retained records', async () => {
const first = new FakeBackend()
const { ctx, fiber } = await setup(first, 'on-demand')
const session = liveSession(ctx, 'on-demand-reload')
session.append('turn/start', { turn: 1 })
await fiber.dispose()
expect(first.records).toEqual([])
const second = new FakeBackend()
let coordinator!: TelemetryCoordinator
await ctx.plugin({
name: 'fake-telemetry-after-on-demand-reload',
inject: ['sessions'],
apply: (inner: Context) => {
coordinator = new TelemetryCoordinator(inner, second, 'on-demand')
},
})
coordinator.captureSession(session)
expect(second.ledger().map(record => record.attributes['event.seq'])).toEqual([0])
})
it('registers no continuous capture, flush, or ops listeners', async () => {
const { ctx, backend, coordinator, fiber } = await setup(new FakeBackend(), 'on-demand')
const redact = vi.fn((_record: TelemetryRecord, next: () => TelemetryRecord) => next())
ctx.on('telemetry/record', redact)
const session = liveSession(ctx, 'on-demand-ledger-only')
session.append('turn/start', { turn: 1 })
await ctx.parallel('session/flush', session)
const agent = { id: 'agent-1', session } as Agent
ctx.emit('agent/error', { agent, turn: 1, step: 1, error: new Error('local only') })
expect(backend.flush).not.toHaveBeenCalled()
expect(backend.records).toEqual([])
expect(redact).not.toHaveBeenCalled()
coordinator.captureSession(session)
expect(redact).toHaveBeenCalledTimes(1)
await fiber.dispose()
expect(backend.records.map(record => record.channel)).toEqual(['ledger'])
})
})
describe('TelemetryCoordinator adoption', () => {
it('exports an unpublished suffix without re-exporting constructor history', async () => {
const backend = new FakeBackend()

Some files were not shown because too many files have changed in this diff Show More