Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine

# Conflicts:
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/session.i18n.yaml
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
This commit is contained in:
_Kerman
2026-07-27 21:51:37 +08:00
110 changed files with 3760 additions and 209 deletions
@@ -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/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md
2026-07-27-question-composer-rows-do-not-shrink.md: 2e0e9b9ca6b141a200ba53d8b6f6f0cad5f7e89d
2026-07-27-question-composer-rows-do-not-shrink.zh.md: 845d9ee883ad1c2c7abc63a01fce626be924b87f
@@ -0,0 +1,50 @@
# Agent Note: Question-composer option rows are scroll content, not the slack absorber
Status: implemented
English | [中文](2026-07-27-question-composer-rows-do-not-shrink.zh.md)
## Problem
The question composer card is capped against the viewport (`max-height: min(60vh, 520px)`) and scrolls its option list, so the header and the footer actions stay reachable on long question batches. When the composer seat got short — a small window, or a short viewport with the details panel open — the option rows rendered on top of each other and on top of the question title.
The cap was not the defect; the distribution of the shortfall was. `.options` is a `flex-direction: column` box whose children default to `flex-shrink: 1`, so under-allocation shrank the rows first instead of overflowing the scroll container. A row shrank to its `min-height: 42px` while `.optionCopy` kept the taller intrinsic height its wrapped copy needs (two lines for an option with a description). With `align-items: center`, the copy is then centered on a box shorter than itself and paints outside the row's border box in both directions — over the title above and the next row below. Measured on the shipped client at 900x440: 6.5px of copy outside the row box, growing to 10px at 380px tall, while `.options` reported `scrollHeight === clientHeight` and therefore never offered a scrollbar.
Only rows whose copy wraps can reproduce it. A row whose copy fits on one line has slack between its content and its 42px minimum, so shrinking it stays invisible — which is why the pre-existing e2e fixture (options `Blue`/`Green`, no descriptions) rendered correctly at every size.
## Decision
`.option` and `.custom` declare `flex-shrink: 0`.
The rows are the scroll content of a capped card; the card's overflow belongs to `.options`, which already owns `overflow-y: auto` and `min-height: 0`. Pinning the children makes the shortfall reach that scroll container instead of being absorbed by the rows, which is the behavior the cap was designed for. The alternative — letting rows shrink but keeping the copy inside them — would require clipping or ellipsizing option descriptions at exactly the sizes where the user most needs to read them.
`.header` and `.footer` already carried `flex-shrink: 0` for the same reason at the card level; the option list's children were the missing half of that rule.
## Alternatives considered
**Clip or ellipsize the copy inside a shrunk row (`overflow: hidden` on `.option`).** This removes the overlap with one declaration and no layout rethink. Rejected because it trades a visible defect for a silent one: the row keeps its 42px, and the second line of an option description simply disappears at exactly the sizes where the card is tightest. The description is decision-relevant content, not decoration.
**Drop `align-items: center` for `align-items: flex-start`.** The copy would grow downward only, so it would no longer paint over the title above. It does not fix anything: a shrunk row still overflows onto the row below, and the fix would silently change the vertical alignment of every option row at every size, including the common one.
**Remove the card's `max-height` cap so nothing is ever squeezed.** No shortfall means no distribution problem. Rejected because the cap is what keeps the header and the footer actions on screen for a long question batch; removing it reintroduces the failure the cap exists to prevent (the composer seat is a fixed-height conversation column with `overflow: hidden`, so an uncapped card loses its own submit button instead).
**Cap the wrapped copy at one line (`white-space: nowrap` plus ellipsis on `.description`).** Rows would never wrap, so they could never overflow when shrunk. Rejected for the same reason as clipping, plus it degrades the wide-viewport rendering — where there is ample room — to fix a narrow-viewport defect.
## Consequences
- A squeezed composer scrolls its option list instead of overlapping it: at 900x380 the list reports `scrollHeight` 200 against `clientHeight` 114 and offers a scrollbar, where before it reported them equal and offered none.
- Option rows keep their full wrapped copy at every viewport size. Nothing is clipped or ellipsized, and the wide-viewport rendering is unchanged (the rule only takes effect when the flex box is under-allocated).
- The card now reaches its scroll state sooner, since the shortfall is no longer partly absorbed by the rows. That is the intended behavior of the cap, and it means a short seat shows a scrollbar in cases that previously showed a silently mis-painted list.
- The scenario's recorded question is longer than it needs to be for the round trip it primarily tests. That cost is deliberate: the layout invariant is unfalsifiable without wrapping copy, and a second fixture for one CSS rule would be worse.
## Verification
The web e2e composer scenario asserts the invariant on the live composer at three squeezed seat heights (900x520 / 440 / 380): every option row's children stay inside the row's border box. Two guards keep the assertion from holding vacuously — at least one row must be wrapped (the only shape that overflows) and `.options` must actually be scrolling (proof the seat is genuinely capped). The scenario's recorded question now carries long option descriptions for exactly that reason; without wrapping copy the assertion cannot fail.
Confirmed both directions against the built client: with `flex-shrink: 0` reverted the scenario fails (`scrolls: false`, 6.5px spill), and with it restored it passes. A standalone geometry sweep over 340 viewport sizes (420-1600 x 320-960) went from 86 sizes with copy outside a row box to zero.
The assertion is replay-only: record mode must reach the fixture write rather than aborting on layout. Note that the composer ships as a client-module bundle, so `pnpm run build:web` alone does not pick up a change to `QuestionComposer.module.css` — the package build must run for the browser lane to see it.
Reproducing the shortfall requires a short viewport, not a short container. The cap is `min(60vh, 520px)`, so shrinking the conversation column below the card's own height clips the card without under-allocating it — the rows keep their full height and nothing spills. Anything demonstrating or measuring this defect outside the e2e scenario has to change the viewport.
A stale `lib/` makes the browser lane assert against an older client than the tree, and a `pnpm run build` that fails part-way leaves exactly that: the packages built before the failure are current, the rest are not. Refreshing a golden in that state records the older client's surface. Confirm the build exited zero before capturing, and note that untracked directories under `packages/` are compiled too — a leftover from another branch can fail the build for reasons the diff does not explain.
@@ -0,0 +1,50 @@
# Agent Note: 提问 composer 的选项行是滚动内容,而非空间不足时的吸收方
Status: implemented
[English](2026-07-27-question-composer-rows-do-not-shrink.md) | 中文
## 问题
提问 composer 的卡片会按视口设上限(`max-height: min(60vh, 520px)`),并让选项列表自行滚动,这样在成批提问时标题和底部操作按钮始终可达。但当 composer 的容器变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),选项行会互相叠在一起,也会叠到问题标题上。
缺陷不在这个高度上限,而在高度不足时由谁来吸收。`.options` 是一个 `flex-direction: column` 的盒子,其子元素默认取 `flex-shrink: 1`,因此空间不足时首先被压缩的是各个选项行,而不是让滚动容器产生溢出。一行会被压到它的 `min-height: 42px`,而 `.optionCopy` 仍保持文案折行后所需的更大固有高度(带描述的选项会占两行)。由于 `align-items: center`,文案于是以一个比自身更矮的盒子为基准居中,并向上下两个方向画到该行边框盒之外——向上盖住标题,向下盖住下一行。在实际发布的客户端上于 900x440 处实测:文案有 6.5px 落在行盒之外,视口高度降到 380px 时增至 10px,而 `.options` 报告的 `scrollHeight` 等于 `clientHeight`,因此始终不会给出滚动条。
只有文案会折行的选项行才能复现该问题。文案单行即可容纳的行,其内容与 42px 最小高度之间尚有余量,被压缩也看不出来——这正是既有 e2e fixture(测试前置数据)(选项为 `Blue``Green`,无描述)在任何尺寸下都渲染正常的原因。
## 决策
`.option``.custom` 声明 `flex-shrink: 0`
在设有高度上限的卡片中,这些行是滚动内容;卡片的溢出归 `.options` 承担,它本就持有 `overflow-y: auto``min-height: 0`。把子元素固定住之后,高度不足会传导到那个滚动容器,而不再被行本身吸收,这正是该高度上限设计时想要的行为。另一种做法是允许行被压缩,但把文案约束在行内,那就必须在用户最需要阅读选项描述的尺寸上对其做裁剪或省略号处理。
`.header``.footer` 出于同样的原因,已在卡片层级带有 `flex-shrink: 0`;选项列表的子元素正是这条规则缺失的另一半。
## 曾考虑的替代方案
**在被压缩的行内裁剪文案或加省略号(对 `.option` 设 `overflow: hidden`)。** 这样一条声明就能消除重叠,且不必重新考虑布局。之所以否决:它把一个可见缺陷换成了一个无声缺陷——行仍保持 42px,而选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。描述属于影响决策的内容,不是装饰。
**把 `align-items: center` 改为 `align-items: flex-start`。** 文案就只会向下生长,因此不再向上盖住标题。但这什么也没修好:被压缩的行依然会溢出到下一行上,而且这一改动会在所有尺寸下(包括常见尺寸)无声改变每个选项行的垂直对齐。
**移除卡片的 `max-height` 上限,使其永远不会被压缩。** 没有高度不足,就没有分配问题。之所以否决:正是这个上限保证成批提问时标题和底部操作按钮留在屏幕内;移除它会重新引入该上限本就为之存在的失败(composer 所处的容器是一个固定高度、`overflow: hidden` 的会话列,因此不设上限的卡片会连自己的提交按钮一起丢掉)。
**把折行文案限制为单行(对 `.description` 设 `white-space: nowrap` 加省略号)。** 行永远不会折行,因此被压缩时也永远不会溢出。否决理由与裁剪相同,此外它还为了修一个窄视口缺陷,而牺牲了空间充裕的宽视口渲染效果。
## 后果
- 被压缩的 composer 会滚动其选项列表,而不是让它互相重叠:在 900x380 处,该列表报告 `scrollHeight` 为 200、`clientHeight` 为 114,并给出滚动条;此前两者相等,不给滚动条。
- 选项行在任何视口尺寸下都保留完整的折行文案。不裁剪、不加省略号,宽视口下的渲染保持不变(该规则仅在 flex 盒子空间不足时才生效)。
- 由于高度不足不再被行部分吸收,卡片现在更早进入滚动状态。这正是该高度上限想要的行为,也意味着在此前只会无声画错列表的情形下,矮容器现在会显示滚动条。
- 该场景录制的问题,比它主要测试的那次往返所需的长度更长。这个代价是有意付出的:没有折行文案,该布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。
## 验证
Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440380)上,对活动的 composer 断言该运行时不变式:每个选项行的子元素都留在该行的边框盒之内。两道守卫防止该断言空洞地成立——必须至少有一行处于折行状态(这是唯一会溢出的形态),且 `.options` 必须确实处在滚动状态(证明容器确实触及了高度上限)。该场景录制的问题现在带有较长的选项描述,正是为此;没有折行文案,这条断言不可能失败。
在构建产物客户端上双向确认过:撤销 `flex-shrink: 0` 后该场景失败(`scrolls: false`,6.5px 溢出),恢复后通过。一次覆盖 340 种视口尺寸(420-1600 x 320-960)的独立几何遍历,从 86 种尺寸存在文案落在行盒之外,降到 0 种。
该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模组包的形式发布,因此单跑 `pnpm run build:web` 不会带上对 `QuestionComposer.module.css` 的改动——必须执行包构建,浏览器测试通道才能看到它。
要复现这种空间不足,需要的是矮视口,而不是矮容器。高度上限为 `min(60vh, 520px)`,因此把会话列压到比卡片自身高度更矮,只会裁剪卡片,而不会让它空间不足——各行仍保持完整高度,也不会有任何溢出。凡是在 e2e 场景之外演示或测量该缺陷的手段,都必须改变视口。
`lib/` 陈旧会让浏览器测试通道对着一个比工作树更旧的客户端做断言,而中途失败的 `pnpm run build` 留下的正是这种状态:失败之前构建的那些包是新的,其余不是。在这种状态下刷新 golden,记录下来的是旧客户端的界面。抓取之前先确认构建以 0 退出;另需注意 `packages/` 下的未跟踪目录同样会被编译——来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。
@@ -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-16-persistent-pty-sessions.md
2026-07-16-persistent-pty-sessions.md: 43c87bb159cfe1ab9f8d3a80c2adf25a57ae6e3b
2026-07-16-persistent-pty-sessions.zh.md: 8afc2103447cc58b1fcbc1062b9564e8ed643477
2026-07-16-persistent-pty-sessions.md: 4fff1742721fa13ea11f1b8ec833e5e5b7e68df8
2026-07-16-persistent-pty-sessions.zh.md: 88e7a0bc4b8af21dc51b6a654a1c9f93760b3e81
@@ -80,6 +80,8 @@ On macOS there is no exact syscall tier. Output silence returns `inferred_idle`
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session.
Once a send settles under any tier, `PtySendOperation.append` stops accepting output, so later child output no longer reaches that settled operation; it still reaches the scrollback, and any send that is active when it arrives. A test that waits for a marker on the operation it started must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed.
`node-pty` data notifications feed one terminal parser. Parser carry state handles control sequences and a trailing carriage return split across callbacks, so a divided CRLF produces one newline rather than a pagination-changing blank line. The implementation normalizes line-oriented output, but it does not promise correct interaction with a full-screen application.
### Model-visible output and durability
@@ -80,6 +80,8 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。
一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation;它仍然会进入 scrollback,以及此时恰好处于活跃状态的任何 send。因此,等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs``timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。
`node-pty` data 通知进入同一个终端 parser。parser 的 carry state 会处理跨 callback 的控制序列和位于 callback 末尾的回车;因此,即使 CRLF 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。
### 模型可见输出与持久性
@@ -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-23-session-telemetry-otel-revival.md
2026-07-23-session-telemetry-otel-revival.md: a58598d8a956d47cb0cf6aa3e659f38314bc4b17
2026-07-23-session-telemetry-otel-revival.zh.md: cc09717e349d5ae2ab5157bf46de30b1823c775f
@@ -0,0 +1,37 @@
# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend
Status: implemented
English | [中文](2026-07-23-session-telemetry-otel-revival.zh.md)
## Problem
Every deployment that wants harness sessions in an observability stack must hand-roll a session-log consumer: subscription, lifecycle handoff, and — hardest — redaction, since the raw log carries file contents and command output that may embed credentials. A telemetry seam and OTel backend shipped once on the `session-telemetry-otlp-rfc` branch (PR #222/#231) but never reached master: the proposal exported raw session events verbatim, which legal review declined. The capture-side design (backend contract, coordinator, handoff cursor, chunk projection) was sound and reviewed; the export-side stance was the blocker.
## Decision
`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.
- **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.
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.
## Alternatives considered
**Implement the runtime-telemetry RFC's outbox (durable spool, per-sink cursors, at-least-once, a `readCommitted` persistence-seam method).** Deferred, not rejected: the SDK stance makes delivery semantics the reporting SDK's territory, and the OTel SDK's own batch pipeline is the honest default. The outbox is a pure additive layer (the `emit()` contract does not move); revive it when a deployment states a crash-loss requirement telemetry must satisfy.
**No in-process redaction point, delegating to receiver-side collector processors.** Rejected — receiver-side redaction ships the secret first and scrubs it second. The waterfall puts an auditable, stackable scrubbing point before bytes leave the process; where the branch version (what PR #222 shipped) had no redaction point at all, every record now passes one.
**A built-in conservative rule set as the waterfall's innermost `next()`.** Rejected: as an SDK we cannot know which patterns are secrets in a given deployment, a shipped list invites false confidence ("redaction is on") while catching only known shapes, and false positives would corrupt exported bodies for consumers who never asked. The seam owns the mechanism; the deployment owns the policy — the innermost `next()` is a pass-through, and rules mount as listeners.
**Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve.
**Full-log replay when no handoff cursor survived (re-export constructor seeds).** Shipped in the first revival round, then narrowed: adoption now replays from the session's construction boundary (`Session.firstLiveSeq`, the constructor-seed length — a fact the session already validated but did not expose; `header.seedLength` cannot serve, it is the durable fork-lineage value and a resumed session's constructor seed is its full stored log). A resumed session's history already shipped from the previous process under the same id, and a fork's inherited prefix already shipped in the parent's stream — re-exporting either re-billed every resume for its full history and doubled query-time counts on OTLP backends with no native ingest dedupe. Receivers stitch fork lineage via `session.parent_id` + `session.seed_length`. What the narrowing gives up, consistently with the at-most-once stance: a resume no longer backfills records the previous process failed to deliver (telemetry unmounted then, or queued at crash) — the full replay's only real benefit, bought at the common case's expense. A deployment that states a backfill requirement needs the deferred outbox above, not replay. The boundary also swallows the synthetic turn closers `SessionPersistence.load()` writes when repairing a crash-interrupted log (they sit below `firstLiveSeq` despite never existing in the previous process) — deliberate, not incidental: exporting a synthetic closer cannot complete the remote turn whose real tail records died in the crashed process's queue, it can only make an incomplete turn look closed. The wire stream stays faithful to what the crashed process actually shipped; receivers read a never-closed turn on a resumed stream as "the previous process died inside it" (the OTel README states the rule), and a later clean `shutdown` marker attests only to the resumed process's exit. Threading the pre-repair boundary through load/prepare so repairs export as live events would couple three packages to un-ship that signal.
**Forwarding the seam's turn-boundary `flush()` hint to the OTel provider's `forceFlush()`.** Shipped in the first revival round, then removed after three review rounds each found a new silent-loss path in the same wrapper state: a dispose racing an in-flight flush (the SDK's concurrent-flush guard makes shutdown's internal drain skip), overlapping hints displacing the retained promise, and the provider's fixed 30-second flush timeout rejecting while the processor still drains. Every path exists only because the forwarding made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree; with no `flush()` implemented, the batch processor is the only flusher, its `scheduledDelayMillis` (already deployment-tunable through the `processor` passthrough) governs export cadence, and `shutdown()`'s drain is complete by construction. Reinstate only if a deployment states a turn-boundary latency requirement `scheduledDelayMillis` cannot meet — and then by calling the retained `BatchLogRecordProcessor`'s own `forceFlush()`, never the provider's timeout-wrapped one.
## 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.
@@ -0,0 +1,37 @@
# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend
Status: implemented
[English](2026-07-23-session-telemetry-otel-revival.md) | 中文
## Problem
每个想把 harness 会话接入可观测性体系的部署方都得手写一套会话日志消费端:订阅、生命周期交接、以及最难的脱敏——原始日志携带文件内容与命令输出,可能内嵌凭据。遥测 seam 和 OTel backend 曾在 `session-telemetry-otlp-rfc` 分支(PR #222/#231)上完成过一版,但从未进入 master:该提案将原始会话事件原样导出,法务评审未予通过。捕获侧设计(backend 契约、coordinator、handoff 游标、chunk 投影)本身合理且经过评审;导出侧的立场才是阻塞点。
## Decision
`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责:
- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend``emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。
- **`telemetry/record` waterfall** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。
- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backendOTel JS SDK 日志管线(`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。
边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。
## Alternatives considered
**实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDKOTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。
**不设进程内脱敏点,交给接收端 collector processor。** 否决——接收端脱敏是先把秘密发出去再擦除。waterfall 在字节离开进程前提供一个可审计、可堆叠的擦除点;分支版本(PR #222 交付的形态)完全没有脱敏点,如今每条记录都必经其一。
**在 waterfall 最内层 `next()` 内置一套保守规则集。** 否决:作为 SDK 我们无法预知某个部署里什么模式算秘密,内置列表只覆盖已知形状却会带来"脱敏已开启"的虚假信心,且误报会替从未要求过的消费者破坏导出 body。seam 拥有机制,部署方拥有策略——最内层 `next()` 原样透传,规则以监听器挂载。
**映射到 OTel spanGenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。
**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:收养现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。该边界同样吞掉 `SessionPersistence.load()` 修复被崩溃打断的日志时写入的合成轮次关闭事件(它们落在 `firstLiveSeq` 之前,尽管在上一个进程中从未存在过)。这是有意为之,而非附带效果:远端轮次的真实尾部记录已随崩溃进程的队列一同消亡,导出合成关闭事件无法补全该轮次,只会让一个未完成的轮次看起来已经关闭。导出的流忠实于崩溃进程实际发出的内容;接收端会把恢复后的流中一个从未关闭的轮次读作「上一个进程死在了该轮次之内」(OTel README 陈述了这条规则),其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。若为让修复以实时事件的身份导出而将修复前边界贯穿 load/prepare 传递,将使三个包相互耦合,只为抹除这一信号。
**将 seam 的轮次边界 `flush()` 提示转发到 OTel provider 的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三轮评审在同一份包装层状态中各发现一条新的静默丢失路径——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及 provider 固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用 provider 那个带超时包装的版本。
## Consequences
部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。
@@ -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-27-native-workspace-directory-picker.md
2026-07-27-native-workspace-directory-picker.md: 847b7a7c7844a47e534183008acfdc32e2e08125
2026-07-27-native-workspace-directory-picker.zh.md: e484a2ba5860d43925aba82be31c803716a33bc5
@@ -0,0 +1,51 @@
# Agent Note: Native workspace directory picker
Status: implemented
English | [中文](2026-07-27-native-workspace-directory-picker.zh.md)
## Problem
The desktop GUI asks users to type an absolute path when they add an existing workspace. This is slower and more error-prone than choosing a directory with the operating system's native picker. The GUI is delivered through the local Web carrier, so opening a native dialog also creates a privileged boundary that ordinary remote requests must not cross.
## Decision
Add a single-folder `host.pickDirectory` RPC and expose it through `WorkspacesService`. The workspace menu presents two flat actions: **Open local folder...** and **Create a new workspace**. Selecting a folder reuses the existing `workspace.create({ path })` flow, selects the returned workspace, and starts a blank session.
The workspace manager must upsert the returned workspace before the selection callback runs. A newly adopted directory therefore renders its basename immediately. Reopening an already registered path preserves its existing workspace title.
## Interaction contract
- The picker accepts one directory on macOS, Windows, and Linux.
- Cancelling the system dialog is silent and returns `null`.
- A duplicate path selects the existing workspace.
- A different path whose derived title conflicts with another workspace shows a focused error with **Choose again** and **Cancel** actions.
- Other picker failures show a compact retryable error.
- The existing create-by-name flow remains unchanged.
## Host boundary
The native dialog RPC is accepted only from a loopback socket with same-origin browser metadata. The RPC does not use the default 30-second request timeout because a system dialog may remain open indefinitely; caller and connection aborts still propagate to the platform process.
Platform adapters invoke native tools without a shell:
- macOS: `osascript` and the system folder chooser.
- Windows: PowerShell in STA mode and `FolderBrowserDialog`.
- Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable.
## Alternatives considered
- A custom directory browser duplicates operating-system behavior and permissions, and belongs to the Web implementation rather than this desktop-only change.
- Reusing the manual path field keeps the current error-prone interaction.
- Adding authentication infrastructure for one local native dialog would expand the change beyond its threat model; loopback and same-origin checks are sufficient for this carrier.
## Consequences
The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. Existing workspace creation by name remains available.
The added host, runtime, component, and GUI tests cover the native boundary, request trust checks, cancellation and failure handling, existing-path reuse, title conflicts, and the immediate visible-name update. The privileged RPC remains specific to the local desktop carrier; a remote Web directory browser is outside this decision.
## Risks
- Linux desktop environments may provide neither supported picker. The GUI reports that limitation instead of falling back to a typed path.
- Browser metadata varies outside the supported local carrier. The endpoint intentionally rejects requests that cannot prove the required local same-origin context.
@@ -0,0 +1,51 @@
# Agent Note: 原生工作区目录选择器
Status: implemented
[English](2026-07-27-native-workspace-directory-picker.md) | 中文
## 问题
桌面端 GUI 在添加现有工作区时要求用户输入绝对路径。相比使用操作系统原生选择器选取目录,这种操作速度更慢,也更容易出错。GUI 由本地 Web 载体提供,因此打开原生对话框也会形成一条特权边界,普通远程请求不得越过这条边界。
## 决策
新增一个用于选择单个文件夹的 `host.pickDirectory` RPC,并通过 `WorkspacesService` 暴露该 RPC。工作区菜单提供两个平铺操作:**打开本地文件夹…** 和 **创建新工作区**。选定文件夹后,系统复用现有的 `workspace.create({ path })` 流程,选中返回的工作区,并启动一个空白会话。
工作区管理器必须在选择回调运行前插入或更新返回的工作区。因此,新纳入的目录会立即显示其 basename。再次打开已注册的路径时,则保留该工作区现有的标题。
## 交互契约
- 在 macOS、Windows 和 Linux 上,选择器一次只允许选择一个目录。
- 取消系统对话框不会显示提示,并返回 `null`
- 路径重复时,选中现有工作区。
- 如果路径不同,但其派生标题与另一个工作区冲突,则显示明确指出该冲突的错误提示,其中包含 **重新选择****取消** 操作。
- 选择器的其他故障会显示简洁且可重试的错误提示。
- 现有的按名称创建流程保持不变。
## 宿主边界
只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。
平台适配器不经 shell,直接调用原生工具:
- macOS`osascript` 和系统文件夹选择器。
- Windows:采用 STA 模式的 PowerShell 和 `FolderBrowserDialog`
- Linux:使用 `zenity`Zenity 不可用时回退到 `kdialog`
## 考虑过的替代方案
- 自定义目录浏览器会重复实现操作系统的行为和权限逻辑,而且应属于 Web 实现,而非本次仅面向桌面端的变更。
- 继续使用手动路径字段会保留当前容易出错的交互方式。
- 为一个本地原生对话框添加身份认证基础设施,会使变更范围超出其威胁模型;对当前载体而言,回环与同源检查已经足够。
## 后果
当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。现有的按名称创建工作区功能仍可使用。
新增的宿主、运行时、组件和 GUI 测试覆盖原生边界、请求信任校验、取消与故障处理、已有路径复用、标题冲突和可见名称即时更新。该特权 RPC 仍仅面向本地桌面载体;远程 Web 目录浏览器不属于本次决策范围。
## 风险
- Linux 桌面环境可能不提供任何一种受支持的选择器。GUI 会报告这项限制,而不会回退到要求用户输入路径。
- 在受支持的本地载体之外,浏览器元数据可能有所不同。对于无法证明其满足所需本地同源上下文的请求,该端点会按设计拒绝。
+46 -1
View File
@@ -28,7 +28,10 @@ const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
const MODE = webSnapshotMode()
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop.'
// The options carry long descriptions on purpose: the squeeze assertion below
// needs option copy that WRAPS, which is the only shape that reproduces a
// collapsed row painting its copy outside its own box.
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.'
describe('web e2e: resident question composer round trip', () => {
let scaffold: WebScaffold
@@ -79,6 +82,48 @@ describe('web e2e: resident question composer round trip', () => {
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
}
// Squeezed card: the option rows are the capped card's scroll content, so
// shrinking the seat must push overflow into the option list, never
// collapse a row below the height its own copy needs — a collapsed row
// paints its centered copy outside the row box, over the title and the
// neighbouring rows. Measured on the live composer at seat heights that
// force the cap, then restored for the answer gesture below. Replay only:
// record mode must reach the recording write below, not abort on layout.
if (MODE !== 'record') {
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
for (const height of [520, 440, 380]) {
await page.setViewportSize({ width: 900, height })
const squeeze = await composer.evaluate((card) => {
// Role/ARIA selectors, not the CSS-module class names: the built
// client hashes those.
const rows = [...card.querySelectorAll<HTMLElement>(
'[role="radio"], [role="checkbox"], [aria-expanded]',
)]
const spill = rows.map(row => Math.max(...[...row.children].map((child) => {
const box = row.getBoundingClientRect()
const inner = child.getBoundingClientRect()
return Math.max(box.top - inner.top, inner.bottom - box.bottom)
})))
const list = rows[0]?.parentElement ?? null
return {
rows: rows.length,
spill: Math.max(...spill),
// Wrapped copy is the shape that overflows a collapsed row, and a
// scrolling list proves the seat is genuinely capped. Without both,
// the spill assertion would hold vacuously.
wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length,
scrolls: list === null ? false : list.scrollHeight > list.clientHeight,
}
})
expect(squeeze.rows).toBeGreaterThan(0)
expect(squeeze.wrappedRows).toBeGreaterThan(0)
expect(squeeze.scrolls).toBe(true)
// Sub-pixel tolerance: every row's copy stays inside its border box.
expect(squeeze.spill).toBeLessThan(0.6)
}
await page.setViewportSize(original)
}
await composer.getByRole('radio', { name: 'Blue' }).click()
// Submit: Enter on the focused option (the composer's documented submit).
await composer.getByRole('radio', { name: 'Blue' }).press('Enter')
-1
View File
@@ -128,7 +128,6 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-
// Session+Agent and the provider swaps in the live blank-session hero.
fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
.find(el => el.getAttribute('aria-haspopup') === 'menu')!)
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
@@ -6,18 +6,24 @@
- tab "Chat" [selected]
- tab "Trajectory"
- tab "Waterfall"
- text: Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop.
- button "Think The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that.":
- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."
- button "复制":
- img
- text: Think The user wants me to use the ask_user_question tool to ask a specific question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". Let me do exactly that.
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
- img
- text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
- button:
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"
- button "Think The user answered \"Blue\". I need to reply with the single word DONE and stop.":
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"
- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
- img
- text: Think The user answered "Blue". I need to reply with the single word DONE and stop.
- text: Think The user answered "Blue". I should now reply with the single word DONE and stop.
- paragraph: DONE
- text: cache hit 99% · 15,978 tokens · 1 turns · 2 steps
- text: cache hit 95% · 8,769 tokens · 1 turns · 2 steps
- textbox "Message the agent"
- button "Add attachment":
- img
@@ -1,31 +1,31 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785001700711,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785001700724,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785001700725,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785001700727,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785001700783,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785001700784,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785001701372,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785001701373,"data":{"turn":1,"step":1,"index":0,"dt":[117,23,0,0,0,1,26,1,0,0,0,0,25,0,0,0,27,1,24,1,0,0,0,27,0,0,0,0,1,34,0,0,0,0,1,17,0,0,0,1,0,27,1,0,0,28,0,0,22,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," to"," ask"," a"," specific"," question"," with"," id"," \"","color","\","," question"," \"","Which"," color"," do"," you"," prefer","?\","," header"," \"","Pick"," one","\","," and"," options"," labeled"," \"","Blue","\""," and"," \"","Green","\"."," Let"," me"," do"," exactly"," that","."]}}
{"type":"assistant/chunk","seq":57,"time":1785001701858,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":58,"time0":1785001701858,"data":{"turn":1,"step":1,"index":1,"dt":[27,1,0,0,0,24,1,0,0,28,0,0,0,0,1,24,0,0,1,0,0,26,0,0,0,0,0,26,0,1,0,0,0,25,0,0,0,0,3,23,1,0,0,0,0,26,1,26],"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\"},"," {\"","label","\":"," \"","Green","\"","}]","}]","}"]}}
{"type":"assistant/chunk","seq":107,"time":1785001702154,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that."}}}}
{"type":"assistant/chunk","seq":108,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}}}}
{"type":"assistant/chunk","seq":109,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":23,"outputTokens":138,"cacheReadTokens":7808,"reasoningTokens":51}}}}
{"type":"assistant/chunk","seq":110,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":111,"time":1785001702159,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that."},{"type":"tool-call","id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":23,"outputTokens":138,"cacheReadTokens":7808,"reasoningTokens":51}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110],"surfaceOp":"append"}
{"type":"tool/call","seq":112,"time":1785001702160,"data":{"turn":1,"step":1,"callId":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}}
{"type":"tool/result","seq":113,"time":1785001702566,"data":{"turn":1,"step":1,"callId":"call_00_evaSJ80aahxJCcpWrfA00887","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[112],"surfaceOp":"append"}
{"type":"step/end","seq":114,"time":1785001702568,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":115,"time":1785001702569,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":116,"time":1785001702948,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":117,"time0":1785001702949,"data":{"turn":1,"step":2,"index":0,"dt":[84,26,1,0,0,0,0,29,0,0,22,0,1,0,0,0,27,1],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," need"," to"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}}
{"type":"assistant/chunk","seq":136,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":137,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":138,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":139,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user answered \"Blue\". I need to reply with the single word DONE and stop."}}}}
{"type":"assistant/chunk","seq":140,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":141,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":179,"outputTokens":22,"cacheReadTokens":7808,"reasoningTokens":19}}}}
{"type":"assistant/chunk","seq":142,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":143,"time":1785001703141,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I need to reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":179,"outputTokens":22,"cacheReadTokens":7808,"reasoningTokens":19}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142],"surfaceOp":"append"}
{"type":"step/end","seq":144,"time":1785001703142,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":145,"time":1785001703142,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}}
{"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}}
{"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}}
{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}}
{"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}}
{"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"}
{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}
{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"}
{"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":137,"time0":1785150170606,"data":{"turn":1,"step":2,"index":0,"dt":[111,29,0,0,0,1,34,0,0,17,1,29,0,0,0,0,1,26],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," should"," now"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}}
{"type":"assistant/chunk","seq":156,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":157,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":158,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":159,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."}}}}
{"type":"assistant/chunk","seq":160,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":161,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}}}}
{"type":"assistant/chunk","seq":162,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":163,"time":1785150170857,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}},"sourceEventSeqs":[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162],"surfaceOp":"append"}
{"type":"step/end","seq":164,"time":1785150170858,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":165,"time":1785150170858,"data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -10,10 +10,10 @@
- img
- radiogroup:
- radio "Blue":
- text: 1 Blue
- text: 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- img
- radio "Green":
- text: 2 Green
- text: 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- img
- button "其他,请填写自定义答案":
- img
-1
View File
@@ -45,7 +45,6 @@ export function probeFreePort(): Promise<number> {
*/
export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise<void> {
await page.getByRole('button', { name: 'Choose workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
await dialog.waitFor({ timeout: 10_000 })
+1 -2
View File
@@ -134,10 +134,9 @@ function setComposerText(composer: HTMLElement, value: string): void {
expect((composer as HTMLTextAreaElement).value).toBe(value)
}
/** Drive the picker's create flow: chip → Create workspace → name dialog. */
/** Drive the picker's create flow: chip → Create a new workspace → name dialog. */
async function createWorkspaceViaPicker(name: string): Promise<void> {
fireEvent.click(workspaceChip())
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' }))
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
+23 -21
View File
@@ -30,9 +30,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let pickedDirectory: string | null = null
beforeAll(async () => {
scaffold = await launchWebScaffold({})
scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({
rpcId: request.rpcId,
result: { ok: true, value: { path: pickedDirectory } },
})
// Seed one cold session (Ungrouped bucket) for the flat view + hover card.
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
@@ -55,8 +60,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create'))
const createByName = async (name: string): Promise<void> => {
await page.getByRole('button', { name: 'Create workspace' }).click()
// The pick menu's Create workspace submenu opens on hover/focus.
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
await dialog.waitFor({ timeout: 10_000 })
@@ -134,14 +137,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
collect()
})
// Register the scaffold's existing project directory through the real UI.
pickedDirectory = scaffold.workspaceCwd
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
const useFolder = page.getByRole('dialog', { name: 'Use an existing folder' })
await useFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd)
await useFolder.getByRole('button', { name: 'Use folder' }).click()
await expect.poll(() => useFolder.count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
{ timeout: 10_000 },
).not.toBeUndefined()
const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
await workspace.attachSession(SessionId(SEED_ID))
@@ -197,13 +200,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
// Re-registering the exact deleted path immediately, without a reload, is
// a supported reversible flow. It creates a fresh Workspace id without
// re-adopting the retained Session.
pickedDirectory = scaffold.workspaceCwd
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' })
await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd)
await reuseFolder.getByRole('button', { name: 'Use folder' }).click()
await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
{ timeout: 10_000 },
).not.toBeUndefined()
const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
expect(reregistered?.id).toBeDefined()
expect(reregistered?.id).not.toBe(workspace.id)
@@ -269,13 +272,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
collect()
})
pickedDirectory = oldPath
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Use an existing folder' }).click()
const adopt = page.getByRole('dialog', { name: 'Use an existing folder' })
await adopt.getByLabel('Existing folder path').fill(oldPath)
await adopt.getByRole('button', { name: 'Use folder' }).click()
await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
await expect.poll(
() => scaffold.ctx.workspace.resolveByPath(oldPath),
{ timeout: 10_000 },
).not.toBeUndefined()
const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath)
if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered')
@@ -288,7 +291,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined()
await page.getByRole('button', { name: 'Create workspace' }).click()
await page.getByRole('menuitem', { name: 'Create workspace' }).hover()
await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
const create = page.getByRole('dialog', { name: 'Create a new workspace' })
await create.getByLabel('New workspace name').fill(title)
+6
View File
@@ -35,6 +35,9 @@ flowchart LR
pkg_tool_bash["tool-bash"]
pkg_hooks_claude["hooks-claude"]
pkg_hooks_codex["hooks-codex"]
pkg_session_telemetry["session-telemetry"]
svc_telemetry["ctx.telemetry<br/>Session telemetry seam"]
pkg_session_telemetry_otel["session-telemetry-otel"]
pkg_storage["storage"]
svc_storage["ctx.storage<br/>Non-session storage hub"]
pkg_storage_json["storage-json"]
@@ -180,6 +183,8 @@ flowchart LR
pkg_session_query --> svc_sessionQuery
pkg_session_query_sqlite --> svc_sessionQuery
pkg_session_reference --> svc_sessionReferences
pkg_session_telemetry --> svc_telemetry
pkg_session_telemetry_otel --> svc_telemetry
pkg_session_title --> svc_sessionTitle
pkg_session_title_all_messages_llm --> svc_sessionTitle
pkg_session_title_first_message_llm --> svc_sessionTitle
@@ -311,6 +316,7 @@ flowchart LR
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |
+35
View File
@@ -1098,6 +1098,40 @@ export interface Config {
Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts)
## `@deepseek-ai/dsh-session-telemetry-otel`
Requires: `sessions`
```ts config-catalog
/**
* Plugin configuration: two verbatim SDK option shapes plus nothing else.
* `exporter.url` is the one field this package validates itself — required,
* no default, must parse as an `http(s)` URL — because a missing endpoint
* must fail at plugin load, not at first export.
*/
export interface Config {
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
* `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
* `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
* 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. */
url?: string
}
/**
* Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
* which this plugin fills); the SDK owns and documents these knobs.
*/
processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
}
```
Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:40`](../packages/telemetry/session-telemetry-otel/src/index.ts)
## `@deepseek-ai/dsh-session-title`
Requires: `sessions`
@@ -2123,6 +2157,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts))
- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
- `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts))
- `@deepseek-ai/dsh-session-telemetry` ([`packages/telemetry/session-telemetry/src/index.ts`](../packages/telemetry/session-telemetry/src/index.ts))
- `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts))
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
- `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts))
+29
View File
@@ -820,6 +820,35 @@ Emitted when any prompt provider changes. This registry notification is unfilter
Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts)
## `telemetry/*`
### `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.
```ts cordis-catalog
/**
* 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.
* @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
*/
'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord
```
Source: [`packages/telemetry/session-telemetry/src/index.ts:41`](../../packages/telemetry/session-telemetry/src/index.ts)
## `tools/*`
### `tools/change` — emit
+24 -1
View File
@@ -1366,7 +1366,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
Source: [`packages/core/session/src/index.ts:600`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:614`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
@@ -1751,6 +1751,29 @@ Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-da
Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts)
## `ctx.telemetry` — `Telemetry` (abstract seam)
The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side.
```ts cordis-catalog
/**
* See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home.
* @param record - the logical record to report; owned by the backend after the call.
*/
abstract emit(record: TelemetryRecord): void
/** See {@link TelemetryBackend.flush}. */
flush?(): void
/**
* See {@link TelemetryBackend.shutdown}.
* @returns resolves when the backend's pipeline has quiesced.
*/
abstract shutdown(): Promise<void>
```
Source: [`packages/telemetry/session-telemetry/src/index.ts:135`](../../packages/telemetry/session-telemetry/src/index.ts)
## `ctx.tokenMeter` — `TokenMeterService`
Replay owner for one service-wide estimator and isolated per-session folds.
+2 -2
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/session.md
session.md: bc8744887e66726deff732f656bde7fed5a73639
session.zh.md: 9d00cc891991d5d37968fcf5344291cd2eec1f08
session.md: 058236cb628f0e517fe18b0e4276dba46bd6b0b0
session.zh.md: 2d8022c7892828a30094728a1449d16dddd2fd89
+12
View File
@@ -342,6 +342,18 @@ declare class Session {
readonly header: SessionHeader;
/** The session identity, derived from its durable header's single copy. */
get id(): SessionId;
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events below it entered through construction —
* replay, fork, or resume — and were never published on the `session/event`
* firehose (constructor seeds do not emit), so consumers that replay the
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* session's constructor seed is its full stored log, while its header keeps
* the original fork value — this field is the in-process construction fact
* and is deliberately not persisted.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
/**
* An immutable snapshot of the append-only event log. The snapshot is reused
+12
View File
@@ -344,6 +344,18 @@ declare class Session {
readonly header: SessionHeader;
/** The session identity, derived from its durable header's single copy. */
get id(): SessionId;
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events below it entered through construction —
* replay, fork, or resume — and were never published on the `session/event`
* firehose (constructor seeds do not emit), so consumers that replay the
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* session's constructor seed is its full stored log, while its header keeps
* the original fork value — this field is the in-process construction fact
* and is deliberately not persisted.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);
/**
* An immutable snapshot of the append-only event log. The snapshot is reused
+6 -5
View File
@@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:419`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:419`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) |
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
@@ -31,10 +31,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:53`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../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) |
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
@@ -45,6 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../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`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../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:113`](../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) |
+13
View File
@@ -220,6 +220,10 @@ flowchart TD
pkg_tasks_local["tasks-local"]
pkg_tool_tasks["tool-tasks"]
end
subgraph group_telemetry["packages/telemetry"]
pkg_session_telemetry["session-telemetry"]
pkg_session_telemetry_otel["session-telemetry-otel"]
end
subgraph group_workflow["packages/workflow"]
pkg_tool_ralph["tool-ralph"]
pkg_tool_workflow["tool-workflow"]
@@ -491,6 +495,9 @@ flowchart TD
pkg_tasks --> pkg_brand
pkg_tasks --> pkg_invariants
pkg_tasks --> pkg_session
pkg_session_telemetry --> pkg_agent
pkg_session_telemetry --> pkg_invariants
pkg_session_telemetry --> pkg_session
pkg_workflow --> pkg_agent
pkg_workflow --> pkg_brand
pkg_workflow --> pkg_invariants
@@ -570,6 +577,10 @@ flowchart TD
pkg_tasks_local --> pkg_invariants
pkg_tasks_local --> pkg_tasks
pkg_tasks_local --> pkg_timeout
pkg_session_telemetry_otel --> pkg_invariants
pkg_session_telemetry_otel --> pkg_llm
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
@@ -954,6 +965,7 @@ flowchart TD
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) |
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
@@ -969,6 +981,7 @@ flowchart TD
| [`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) |
| [`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` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`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) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
#!/usr/bin/env node
/**
* Test driver: start a mock OTLP/HTTP collector, boot the telemetry Loader
* composition against it, run one turn whose prompt carries a fixture
* credential, then persist everything the collector captured to
* `./otlp-captures.json` for the e2e's inspect step.
*/
import { writeFile } from 'node:fs/promises'
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'
const configPath = process.argv[2]
if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path')
const captures: unknown[] = []
const server = createServer((request, response) => {
const chunks: Buffer[] = []
request.on('data', chunk => chunks.push(chunk as Buffer))
request.on('end', () => {
captures.push(JSON.parse(Buffer.concat(chunks).toString()))
response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
})
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('collector has no port')
process.env.DSH_TELEMETRY_E2E_URL = `http://127.0.0.1:${address.port}/v1/logs`
const ctx = await boot('telemetry-otel-e2e', resolveConfigPath(configPath, undefined))
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' })
} finally {
await ctx.fiber.dispose()
}
await writeFile('./otlp-captures.json', JSON.stringify(captures))
server.close()
server.closeAllConnections()
@@ -0,0 +1,28 @@
# Test-only composition: session-telemetry-otel through the real Loader/app
# 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: cli-mock-llm
name: './cli-mock-llm.ts'
- id: telemetry-redact-rule
name: './telemetry-redact-rule.ts'
- id: bash
name: '@deepseek-ai/dsh-bash-local'
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
exporter:
url: !!js process.env.DSH_TELEMETRY_E2E_URL
- id: cli-agent
name: '@deepseek-ai/dsh-cli-demo'
config:
provider: cli-mock
model: cli-mock
persona: 'Test the session-telemetry-otel plugin.'
persistenceRoot: './.sessions'
persistenceCompression: 'none'
workspaceContext: false
@@ -0,0 +1,29 @@
import type { Context } from 'cordis'
/**
* Deployment-style redaction rule for the telemetry e2e: scrubs the fixture
* credential from body strings, exactly as a real deployment would mount its
* own rules on the `telemetry/record` waterfall.
*/
const SECRET = /sk-e2efixture[0-9]+/g
const PLACEHOLDER = '[E2E-REDACTED]'
function scrub(value: unknown): unknown {
if (typeof value === 'string') return value.replace(SECRET, PLACEHOLDER)
if (Array.isArray(value)) return value.map(scrub)
if (value !== null && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrub(entry)]))
}
return value
}
export const name = 'telemetry-redact-rule'
/** Mount the fixture scrub rule onto the redact waterfall. */
export function apply(ctx: Context): void {
ctx.on('telemetry/record', (_record, next) => {
const record = next()
return { ...record, body: scrub(record.body) }
})
}
+1
View File
@@ -42,6 +42,7 @@
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
"@deepseek-ai/dsh-session-query": "workspace:*",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:*",
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:*",
"@deepseek-ai/dsh-spill-local": "workspace:*",
"@deepseek-ai/dsh-spill-policy": "workspace:*",
"@deepseek-ai/dsh-tui-demo": "workspace:*",
+12
View File
@@ -34,6 +34,8 @@
"headless-agent/tests/fixtures/goal-domain/seed-goal.ts",
"headless-agent/tests/fixtures/time-context-driver.ts",
"headless-agent/tests/fixtures/time-context-mock-llm.ts",
"headless-agent/tests/fixtures/telemetry-otel-driver.ts",
"headless-agent/tests/fixtures/telemetry-redact-rule.ts",
"acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts",
"tui-agent/tests/fixtures/tui-scripted-llm.ts",
"acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts",
@@ -182,6 +184,16 @@
"tests/**/*.ts"
]
},
"packages/telemetry/session-telemetry-otel": {
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"project": [
"src/**/*.ts",
"tests/**/*.ts"
]
},
"packages/util/brand": {
"project": [
"src/**/*.ts"
+3 -3
View File
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 911f18547120eb3dbbc9e42bbcd41e3b6d518cfe
README.zh.md: d6e1f0bf9b38b40944f8e3cebea3f6d90dcaceb5
# pnpm run verify-translation-pairing --write packages/README.md
README.md: d16e395a42e491461c0862227205931894c27e39
README.zh.md: 3fb4181ce7ae7b0d79a13ca4358b9df39d83ef1f
+1
View File
@@ -37,6 +37,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
+1
View File
@@ -37,6 +37,7 @@
| [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 |
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
@@ -704,6 +704,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
pickDirectory: request => ok(request, { path: null }),
},
workspace: {
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
@@ -952,6 +953,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request)
+11 -1
View File
@@ -5,6 +5,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
import { isTrustedNativeDialogRequest } from './native-dialog-request.ts'
export { API_PATH } from './api-path.ts'
@@ -23,7 +24,16 @@ export function apply(ctx: Context): void {
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: (req, res) => bridge(req, res, apiHandler),
handler: async (req, res) => {
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
if (pathname === `${API_PATH}/host.pickDirectory`
&& !isTrustedNativeDialogRequest(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
await bridge(req, res, apiHandler)
},
}
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
}
@@ -0,0 +1,52 @@
/** Trust check for browser requests that can open an operating-system dialog. */
import type { IncomingHttpHeaders } from 'node:http'
interface NativeDialogRequest {
headers: IncomingHttpHeaders
socket: { remoteAddress?: string | undefined }
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
function isLoopback(address: string | undefined): boolean {
if (address === undefined) return false
if (address === '::1') return true
const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
const first = ipv4.split('.')[0]
return first === '127'
}
function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/**
* Require a local socket plus browser-controlled same-origin metadata.
* @param request - the node HTTP request facts used by the carrier guard.
* @returns true only for a same-origin browser request whose peer and URL are loopback.
*/
export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean {
if (!isLoopback(request.socket.remoteAddress)) return false
if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false
const origin = header(request.headers, 'origin')
const host = header(request.headers, 'host')
if (origin === undefined || host === undefined) return false
try {
const parsed = new URL(origin)
const hostUrl = new URL(`http://${host}`)
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
&& parsed.host === host
&& isLoopbackHostname(parsed.hostname)
&& isLoopbackHostname(hostUrl.hostname)
} catch {
return false
}
}
@@ -52,6 +52,8 @@ export class FakeApiClient implements IApiClient {
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -70,6 +72,7 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
}
readonly workspace: IApiClient['workspace'] = {
@@ -0,0 +1,47 @@
import { EventEmitter } from 'node:events'
import { Readable } from 'node:stream'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { describe, expect, it } from 'vitest'
import { bridge } from '../src/http-bridge.ts'
describe('HTTP bridge abort', () => {
it('aborts a pending native picker request when the browser disconnects', async () => {
const body = JSON.stringify({
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
})
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
Object.assign(request, {
url: '/api/host.pickDirectory',
method: 'POST',
headers: { 'content-type': 'application/json' },
})
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead() { return this },
write() { return true },
end() { this.writableEnded = true; return this },
}) as unknown as ServerResponse
let resolveStarted!: () => void
const started = new Promise<void>((resolve) => { resolveStarted = resolve })
let carrierSignal: AbortSignal | undefined
const pending = bridge(request, response, {
fetch: async (input) => {
const fetchRequest = input as Request
carrierSignal = fetchRequest.signal
resolveStarted()
if (!fetchRequest.signal.aborted) {
await new Promise<void>((resolve) => {
fetchRequest.signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
return Response.json({ aborted: fetchRequest.signal.aborted })
},
})
await started
response.emit('close')
await pending
expect(carrierSignal?.aborted).toBe(true)
})
})
@@ -0,0 +1,57 @@
import type { IncomingHttpHeaders } from 'node:http'
import { describe, expect, it } from 'vitest'
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
function request(
remoteAddress: string | undefined,
headers: IncomingHttpHeaders = {
host: '127.0.0.1:3080',
origin: 'http://127.0.0.1:3080',
'sec-fetch-site': 'same-origin',
},
) {
return { socket: { remoteAddress }, headers }
}
describe('native dialog request trust', () => {
it('accepts loopback same-origin browser requests', () => {
expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true)
expect(isTrustedNativeDialogRequest(request('::1', {
host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
})
it('rejects remote sockets and requests without matching browser metadata', () => {
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
})
})
@@ -1,6 +1,7 @@
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, inject } from '../src/index.ts'
@@ -27,6 +28,23 @@ describe('connection node half', () => {
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
let status: number | undefined
let body: unknown
const deniedRequest = {
url: '/api/host.pickDirectory',
headers: {
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
},
socket: { remoteAddress: '192.168.1.8' },
} as unknown as IncomingMessage
const deniedResponse = {
writeHead(value: number) { status = value; return this },
end(value?: unknown) { body = value; return this },
} as unknown as ServerResponse
await routes[0]!.handler(deniedRequest, deniedResponse)
expect(status).toBe(403)
expect(body).toBe('forbidden')
await fiber.dispose()
expect(routes).toHaveLength(0)
})
+1 -1
View File
@@ -13,7 +13,7 @@ export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { WorkspacesService } from './workspaces/service.ts'
export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export type { Session } from './sessions/session.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
@@ -21,6 +21,14 @@ export interface WorkspaceListState {
recentWorkspaceId: WorkspaceId | undefined
}
/** Structured create failure for UI flows that distinguish Host business errors. */
export class WorkspaceCreateError extends Error {
constructor(readonly rpcError: RpcError) {
super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`)
this.name = 'WorkspaceCreateError'
}
}
/** Real Workspace object layer and Host actions. */
export class WorkspacesService {
/** UI-facing immutable projection; the manager remains wire truth. */
@@ -37,7 +45,7 @@ export class WorkspacesService {
* @param api - shared wire client.
* @param sessions - lower-level Session service used for recency and blank-session reuse.
*/
constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) {
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) {
this.manager = new WorkspaceManager(api)
this.list = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'pending', error: null,
@@ -158,10 +166,22 @@ export class WorkspacesService {
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
const result = await this.manager.create(input)
if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`)
if (!result.ok) throw new WorkspaceCreateError(result.error)
return result.value.workspace
}
/**
* Open the Host's native directory picker.
* @returns the selected path, or null when the user cancelled.
*/
async pickDirectory(): Promise<string | null> {
const response = await this.api.host.pickDirectory({})
if (!response.result.ok) {
throw new Error(`directory picker failed: ${response.result.error.message}`)
}
return response.result.value.path
}
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
@@ -69,6 +69,8 @@ export class FakeApiClient implements IApiClient {
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -87,6 +89,7 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
}
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
import { WorkspacesService } from '../src/client/workspaces/service.ts'
import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
@@ -210,12 +210,30 @@ describe('WorkspacesService', () => {
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }])
api.onWorkspaceCreate = () => Promise.resolve(ok({
workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
}))
await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' })
expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }])
api.onWorkspaceCreate = () => Promise.resolve(err({
code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
}))
await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/)
const rejected = workspaces.create({ path: '/missing' })
await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/)
await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError)
})
it('passes native directory selection and cancellation through without local state', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
await expect(workspaces.pickDirectory()).resolves.toBeNull()
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
})
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
@@ -90,20 +90,17 @@ export function apply(ctx: Context): void {
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
},
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
selectWorkspace: (workspaceId) => {
void workspaces.connectWorkspace(workspaceId).then((nextId) => {
if (sessionId !== undefined && nextId !== sessionId) {
const from = inputHub.shell(sessionId)
const draft = from.snapshot.draft
if (draft !== '') {
inputHub.shell(nextId).setDraft(draft)
from.setDraft('')
}
selectWorkspace: async (workspaceId) => {
const nextId = await workspaces.connectWorkspace(workspaceId)
if (sessionId !== undefined && nextId !== sessionId) {
const from = inputHub.shell(sessionId)
const draft = from.snapshot.draft
if (draft !== '') {
inputHub.shell(nextId).setDraft(draft)
from.setDraft('')
}
sessions.open(nextId)
}).catch(() => {
// Failure leaves the current Hero state available to retry.
})
}
sessions.open(nextId)
},
}),
}, ConversationRoot)
@@ -175,7 +175,7 @@ export interface ConversationInjected {
* Connect the selected Workspace and open its reusable/new blank session.
* When a blank session is already current, carry its draft to the target.
*/
selectWorkspace(workspaceId: WorkspaceId): void
selectWorkspace(workspaceId: WorkspaceId): Promise<void>
}
/** Business callbacks injected into the strict session content seat. */
@@ -2,8 +2,9 @@
// chain stay mounted across no-session/session transitions. Only the inert
// input body swaps for the strict session InputBar.
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import { DisabledInputBar } from './DisabledInputBar.tsx'
@@ -25,8 +26,23 @@ export function ConversationRoot({
const workspaces = useWorkspaces(s => s)
const [pickerOpen, setPickerOpen] = useState(false)
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
const pickerAnchor = useRef<HTMLButtonElement>(null)
const sessionWorkspace = sessionId === undefined
? undefined
: workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
const pendingWorkspace = workspaces.items.find(
workspace => workspace.workspaceId === pendingWorkspaceId,
)
useEffect(() => {
if (pendingWorkspaceId !== undefined
&& sessionWorkspace?.workspaceId === pendingWorkspaceId) {
setPendingWorkspaceId(undefined)
}
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId])
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
@@ -36,9 +52,10 @@ export function ConversationRoot({
<WorkspaceChip
buttonRef={pickerAnchor}
label={
sessionId === undefined
pendingWorkspace?.title
?? (sessionId === undefined
? workspaceLabel('')
: workspaces.items.find(w => w.sessionIds.includes(sessionId))?.title ?? workspaceLabel(cwd ?? '')
: sessionWorkspace?.title ?? workspaceLabel(cwd ?? ''))
}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
@@ -48,7 +65,10 @@ export function ConversationRoot({
anchorRef: pickerAnchor,
onPick: (workspaceId) => {
setPickerOpen(false)
selectWorkspace(workspaceId)
setPendingWorkspaceId(workspaceId)
void selectWorkspace(workspaceId).catch(() => {
setPendingWorkspaceId(current => current === workspaceId ? undefined : current)
})
},
onClose: () => { setPickerOpen(false) },
})}
@@ -3,7 +3,7 @@
// hero (blank session) and active phases — same textarea DOM node, machine-
// owned draft, and the hero workspace picker (switching = retargetWorkspace).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
@@ -55,7 +55,11 @@ function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): Co
}
}
function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) {
function mount(
snapshot: ConversationSnapshot,
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
) {
const root = sid('root')
const sessions = createSnapshotStore<SessionListState>({
ids: [root, SID],
@@ -76,7 +80,6 @@ function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] =
const inputActions = wiring.actions
const stop = vi.fn()
const open = vi.fn()
const retargetWorkspace = vi.fn()
const slotCalls: string[] = []
let pickerOwner: unknown
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
@@ -158,7 +161,13 @@ describe('ConversationRoot resident composer', () => {
})
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true }),
[
{ ...workspace('one'), sessionIds: [SID] },
{ ...workspace('second'), title: 'Selected Folder' },
],
)
// Hero chrome present, view ring absent.
expect(b.view.getByText("Let's start building")).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
@@ -173,8 +182,9 @@ describe('ConversationRoot resident composer', () => {
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
expect(owner.open).toBe(true)
owner.onPick(wid('second'))
act(() => { owner.onPick(wid('second')) })
expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second'))
expect(b.view.getByText('Selected Folder')).toBeTruthy()
})
it('textarea DOM identity survives the hero → active flip', () => {
@@ -191,6 +201,24 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})
it('rolls the pending workspace label back when switching fails', async () => {
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true }),
[
{ ...workspace('one'), sessionIds: [SID] },
{ ...workspace('second'), title: 'Selected Folder' },
],
selectWorkspace,
)
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
await act(async () => { owner.onPick(wid('second')); await Promise.resolve() })
expect(selectWorkspace).toHaveBeenCalledWith(wid('second'))
expect(b.view.queryByText('Selected Folder')).toBeNull()
expect(b.view.getByText('one')).toBeTruthy()
})
it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const chip = b.view.getByRole('button', { name: 'Choose workspace' })
@@ -128,6 +128,11 @@
gap: 10px;
width: 100%;
min-height: 42px;
/* Rows are the scroll content, never the slack absorber: a shrinkable row
collapses to min-height while its wrapped copy keeps the taller
intrinsic height, and centered content then paints outside the row box —
over the title and the next row. Overflow belongs to .options. */
flex-shrink: 0;
padding: 5px 8px;
border: 1px solid transparent;
border-radius: 12px;
@@ -208,6 +213,9 @@
}
.custom {
/* Same reason as .option: the custom block is scroll content, and shrinking
it pushes its trigger row (and the open textarea) past the footer. */
flex-shrink: 0;
border: 1px solid transparent;
border-radius: 12px;
}
@@ -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-workspace/README.md
README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259
README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951
README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9
README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4
+3 -3
View File
@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals.
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
@@ -19,4 +19,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal.
- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal.
+3 -3
View File
@@ -2,9 +2,9 @@
[English](README.md) | 中文
共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -19,4 +19,4 @@
## 已知限制与暂缓事项
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中
- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试
@@ -250,6 +250,7 @@ export function WorkspaceBrowser({
deleteWorkspace,
insertSessionBefore,
createWorkspace,
pickDirectory,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -367,6 +368,7 @@ export function WorkspaceBrowser({
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)
@@ -9,15 +9,17 @@ import { useCallback, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import {
WorkspaceCreateError,
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
const CREATE_WORKSPACE = '::create-workspace'
const USE_EXISTING = '::use-existing'
const OPEN_LOCAL_FOLDER = '::open-local-folder'
const CREATE_NEW = '::create-new'
type ModalKind = 'path' | 'create' | null
type ModalKind = 'create' | 'folder-error' | null
/** Core flow props: the owner supplies popover control and pick semantics. */
export interface WorkspaceCreateFlowProps {
@@ -29,6 +31,8 @@ export interface WorkspaceCreateFlowProps {
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Open the Host's native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** A real Workspace was picked or created. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
@@ -45,6 +49,7 @@ export function WorkspaceCreateFlow({
anchorRef,
useWorkspaces,
createWorkspace,
pickDirectory,
onPick,
onClose,
}: WorkspaceCreateFlowProps) {
@@ -55,10 +60,11 @@ export function WorkspaceCreateFlow({
[anchorRef],
)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [pathDraft, setPathDraft] = useState('')
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
@@ -68,17 +74,11 @@ export function WorkspaceCreateFlow({
id: workspace.workspaceId as string,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
{
id: CREATE_WORKSPACE,
label: 'Create workspace',
icon: <IconPlusOutline16 size={16} />,
submenu: [
{ id: USE_EXISTING, label: 'Use an existing folder' },
{ id: CREATE_NEW, label: 'Create a new workspace' },
],
},
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
]
const closeModal = (): void => {
@@ -87,12 +87,29 @@ export function WorkspaceCreateFlow({
setModalError(null)
}
const openLocalFolder = (): void => {
onClose()
setModalKind(null)
setModalError(null)
setFolderConflict(false)
setPickingFolder(true)
void pickDirectory().then(async (path) => {
if (path === null) return
const workspace = await createWorkspace({ path })
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
setFolderConflict(
reason instanceof WorkspaceCreateError
&& reason.rpcError.code === 'workspace-name-conflict',
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setModalKind('folder-error')
}).finally(() => { setPickingFolder(false) })
}
const handleSelect = (id: string): void => {
if (id === USE_EXISTING) {
onClose()
setPathDraft('')
setModalError(null)
setModalKind('path')
if (id === OPEN_LOCAL_FOLDER) {
openLocalFolder()
return
}
if (id === CREATE_NEW) {
@@ -120,11 +137,6 @@ export function WorkspaceCreateFlow({
})
}
const confirmPath = (): void => {
const path = pathDraft.trim()
if (path !== '') create({ path })
}
const confirmCreate = (): void => {
if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) {
create({ name: normalizedWorkspaceName })
@@ -144,40 +156,21 @@ export function WorkspaceCreateFlow({
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces</div>}
<Modal
open={modalKind === 'path'}
open={modalKind === 'folder-error'}
onClose={closeModal}
title="Use an existing folder"
title={folderConflict ? 'A workspace with this name already exists' : 'Couldnt open folder'}
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={creating || pathDraft.trim() === ''}
onClick={confirmPath}
>
Use folder
</Button>
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
<Button variant="primary" className={css.modalAction!} onClick={openLocalFolder}>Choose again</Button>
</>
)}
>
<input
className={css.modalInput}
value={pathDraft}
aria-label="Existing folder path"
autoFocus
disabled={creating}
placeholder="/path/to/project"
onChange={(event) => { setPathDraft(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
confirmPath()
}
}}
/>
{creating && <div className={css.modalStatus} role="status">Creating workspace</div>}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
<div className={css.modalError} role="alert">
{folderConflict
? 'Choose a folder with a different name.'
: modalError}
</div>
</Modal>
<Modal
open={modalKind === 'create'}
@@ -235,6 +228,7 @@ export function WorkspacePicker({
onPick,
onClose,
createWorkspace,
pickDirectory,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
@@ -242,6 +236,7 @@ export function WorkspacePicker({
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
onPick={onPick}
onClose={onClose}
/>
@@ -42,6 +42,8 @@ export type WorkspaceBrowserInjected = {
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
@@ -58,6 +60,8 @@ export type WorkspaceBrowserProps =
export type WorkspacePickerInjected = {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory(): Promise<string | null>
}
/**
@@ -44,9 +44,11 @@ export function apply(ctx: ClientContext): void {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
})
// Declaration-aware registration: each owner's declaring apply may activate
// after this one (entry activation order is unconstrained), and a register
@@ -14,16 +14,17 @@ async function bench() {
path: 'name' in input ? `/projects/${input.name}` : input.path,
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
const pickDirectory = vi.fn(async () => '/tmp/picked')
const startSession = vi.fn()
const rename = vi.fn(async () => ({}))
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
const clear = vi.fn()
ctx.provide('workspaces', {
create, startSession, rename, insertSessionBefore,
create, pickDirectory, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear }
}
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
@@ -72,10 +73,14 @@ describe('ui-workspace apply', () => {
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
await browser.createWorkspace({ name: 'project' })
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
await browser.pickDirectory()
expect(b.pickDirectory).toHaveBeenCalledOnce()
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
await picker.createWorkspace({ path: '/tmp/project' })
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
await picker.pickDirectory()
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
})
it('unregisters every entry on teardown', async () => {
@@ -57,6 +57,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
pickDirectory: vi.fn(async () => null),
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)
@@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import type {
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
afterEach(cleanup)
@@ -32,7 +33,11 @@ function anchor(): { current: HTMLElement } {
return { current: element }
}
function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) {
function mount(
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
createWorkspace = vi.fn(),
pickDirectory = vi.fn(async () => null as string | null),
) {
const onPick = vi.fn()
const onClose = vi.fn()
const anchorRef = anchor()
@@ -45,20 +50,19 @@ function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
onPick={onPick}
onClose={onClose}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
/>
)
const view = render(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace,
view, onPick, onClose, createWorkspace, pickDirectory,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void {
const parent = screen.getByRole('menuitem', { name: 'Create workspace' })
fireEvent.mouseEnter(parent.parentElement as HTMLElement)
function chooseItem(name: 'Open local folder' | 'Create a new workspace'): void {
fireEvent.click(screen.getByRole('menuitem', { name }))
}
@@ -73,7 +77,7 @@ describe('WorkspacePicker', () => {
const created = workspace('new', 'New')
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
const input = screen.getByLabelText('New workspace name')
fireEvent.change(input, { target: { value: 'project-one' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
@@ -81,31 +85,78 @@ describe('WorkspacePicker', () => {
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('adopts an existing path through the same immediate create action', async () => {
const created = workspace('adopted')
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
chooseCreateItem('Use an existing folder')
const input = screen.getByLabelText('Existing folder path')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(createWorkspace).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: ' /tmp/project ' } })
fireEvent.keyDown(input, { key: 'Enter' })
const pickDirectory = vi.fn(async () => '/tmp/project')
const b = mount([], createWorkspace, pickDirectory)
chooseItem('Open local folder')
expect(pickDirectory).toHaveBeenCalledOnce()
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('treats native picker cancellation as a silent no-op', async () => {
const b = mount([], vi.fn(), vi.fn(async () => null))
chooseItem('Open local folder…')
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
expect(b.createWorkspace).not.toHaveBeenCalled()
expect(b.onPick).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('shows a name conflict and retries through the native picker', async () => {
const pickDirectory = vi.fn()
.mockResolvedValueOnce('/one/project')
.mockResolvedValueOnce(null)
const createWorkspace = vi.fn(async () => {
throw new WorkspaceCreateError({
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
})
})
const b = mount([], createWorkspace, pickDirectory)
chooseItem('Open local folder…')
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
expect(b.onPick).not.toHaveBeenCalled()
})
it('disables the folder action while the native picker is already open', async () => {
let resolve!: (path: string | null) => void
const pending = new Promise<string | null>((settle) => { resolve = settle })
const b = mount([], vi.fn(), vi.fn(() => pending))
chooseItem('Open local folder…')
expect((screen.getByRole('menuitem', { name: 'Open local folder…' }) as HTMLButtonElement).disabled).toBe(true)
expect((screen.getByRole('menuitem', { name: 'Create a new workspace' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
await act(async () => { resolve(null); await pending })
})
it('reports non-Error native picker failures', async () => {
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
chooseItem('Open local folder…')
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
})
expect(b.createWorkspace).not.toHaveBeenCalled()
})
it('closes a creation modal when the user cancels', () => {
mount([])
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('blocks a create-new name already present in the Workspace list', () => {
const b = mount([workspace('alpha', 'Alpha')])
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true)
@@ -118,7 +169,7 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('fresh', 'same-name')
const b = mount([], vi.fn(() => pending))
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
@@ -134,7 +185,7 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
const createWorkspace = vi.fn(() => pending)
const b = mount([], createWorkspace)
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
const input = screen.getByLabelText('New workspace name')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.change(input, { target: { value: 'broken' } })
@@ -151,7 +202,7 @@ describe('WorkspacePicker', () => {
it('reports non-Error creation failures', async () => {
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
chooseCreateItem('Create a new workspace')
chooseItem('Create a new workspace')
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
@@ -163,7 +214,7 @@ describe('WorkspacePicker', () => {
render(
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
@@ -176,7 +227,7 @@ describe('WorkspacePicker', () => {
render(
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
/>,
)
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
+34 -1
View File
@@ -822,6 +822,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'telemetry',
summary: 'The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.',
methods: [
{
signature: 'abstract emit(record: TelemetryRecord): void',
jsDoc: '/**\n * See {@link TelemetryBackend.emit} — the seam declaration is the contract\'s one home.\n * @param record - the logical record to report; owned by the backend after the call.\n */',
},
{
signature: 'flush?(): void',
jsDoc: '/** See {@link TelemetryBackend.flush}. */',
},
{
signature: 'abstract shutdown(): Promise<void>',
jsDoc: '/**\n * See {@link TelemetryBackend.shutdown}.\n * @returns resolves when the backend\'s pipeline has quiesced.\n */',
},
],
},
{
key: 'tokenMeter',
summary: 'Replay owner for one service-wide estimator and isolated per-session folds.',
@@ -1240,6 +1258,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * Emitted when any prompt provider changes. This registry notification is\n * unfiltered because a global change affects every scope.\n * @mode emit\n */',
summary: 'Emitted when any prompt provider changes.',
},
{
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 */',
summary: 'Transform one outbound record before it reaches the backend.',
},
{
name: 'tools/change',
mode: 'emit',
@@ -1946,7 +1971,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Session',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
},
{
name: 'SessionAvailability',
@@ -2336,6 +2361,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TaskStatus',
declaration: 'export type TaskStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';',
},
{
name: 'TelemetryRecord',
declaration: 'export interface TelemetryRecord {\n channel: \'ledger\' | \'ops\';\n time: number;\n severity: TelemetrySeverity;\n attributes: Record<string, string | number>;\n body: unknown;\n}',
},
{
name: 'TelemetrySeverity',
declaration: 'export type TelemetrySeverity = \'info\' | \'warn\' | \'error\';',
},
{
name: 'TerminalCallView',
declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}',
+14
View File
@@ -296,6 +296,19 @@ export class Session {
return this.header.id
}
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events below it entered through construction —
* replay, fork, or resume — and were never published on the `session/event`
* firehose (constructor seeds do not emit), so consumers that replay the
* log as a publication substitute (telemetry adoption) start here. Distinct
* from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed
* session's constructor seed is its full stored log, while its header keeps
* the original fork value — this field is the in-process construction fact
* and is deliberately not persisted.
*/
readonly firstLiveSeq: number
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
if (seed) {
// Validate the seed to the SAME invariants `append` enforces, so a
@@ -328,6 +341,7 @@ export class Session {
this.log.push(deepFreeze(snapshot))
}
}
this.firstLiveSeq = this.log.length
this.header = snapshotSessionHeader(id, header)
}
+2 -2
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/host/apiproxy/README.md
README.md: adcffaf553bb62f77e38cf8f19f5b48e8d7881e1
README.zh.md: 957b7967c95131f3da6cd25ce395fba8db57b020
README.md: 253c0974cc1427fb7140c332fabdccbfc049ae86
README.zh.md: d79628ca3e1f1d06ad94a0dada16e2208af3cd2c
+3
View File
@@ -14,6 +14,8 @@ The mux stream projects the latest log-backed title as a validated `session/titl
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
@@ -35,3 +37,4 @@ None; this package neither assembles nor sends a provider request.
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Linux native picker requires desktop tooling** — `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; it does not fall back to a custom or typed-path browser.
+3
View File
@@ -14,6 +14,8 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
@@ -35,3 +37,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond``RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
- **预留 seam 不进入 `RpcMethodMap`**`session.fork``prompt.mode: 'inject'``task.list``host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **Linux 原生选择器依赖桌面工具**Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;它不会回退到自定义目录浏览器,也不会要求用户手动输入路径。
+23
View File
@@ -34,6 +34,7 @@ import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
import { pickNativeDirectory } from './native-directory-picker.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -195,6 +196,8 @@ export interface ApiProxyDefaults {
cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
/** Native single-directory picker; injectable for carrier tests. */
pickDirectory?: (signal: AbortSignal) => Promise<string | null>
}
/** The tool/call payload fields the presenter path reads. */
@@ -834,6 +837,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
attachedSessions: ctx.agents.list().length,
}))
},
async pickDirectory(request, signal) {
try {
const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal)
return ok(request, { path })
} catch (error: unknown) {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'directory picker was aborted',
details: {},
})
}
return err(request, {
code: 'internal',
message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
}
},
},
commands: {
@@ -17,3 +17,11 @@ export const hostDescribeValueSchema = z.object({
model: z.string().optional(),
attachedSessions: z.number().int().nonnegative(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
/** host.pickDirectory request payload (empty object literal). */
export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.pickDirectory'>>>
/** host.pickDirectory response value; null means the user cancelled. */
export const hostPickDirectoryValueSchema = z.object({
path: z.string().nullable(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.pickDirectory'>>>
+6
View File
@@ -22,4 +22,10 @@ export interface HostApi {
model?: string
attachedSessions: number
}>>
/** Open the operating system's single-directory picker; cancellation returns null. */
pickDirectory(
request: RpcRequest<{}>,
signal: AbortSignal,
): Promise<RpcResponse<{ path: string | null }>>
}
@@ -23,6 +23,7 @@ export interface RpcMethodMap {
'session.prompt': SessionsApi['prompt']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']
+20 -5
View File
@@ -13,7 +13,7 @@ import { RpcId } from '../api/rpc.ts'
import type { Wire } from '../api/rpc.schema.ts'
import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
import { hostDescribeValueSchema } from '../api/host.schema.ts'
import { hostDescribeValueSchema, hostPickDirectoryValueSchema } from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
sessionCreateValueSchema,
@@ -56,6 +56,7 @@ export interface IApiClient {
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
}
workspace: {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
@@ -90,6 +91,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.prompt': sessionPromptValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema,
@@ -177,13 +179,22 @@ export abstract class AbstractApiClient implements IApiClient {
* Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
* timeout merged with the caller's optional external signal, non-2xx → transport throw.
*/
private async postJson(path: string, body: ClientRequest | ClientResponse, signal: AbortSignal | undefined): Promise<Response> {
const timeout = AbortSignal.timeout(this.timeoutMs)
private async postJson(
path: string,
body: ClientRequest | ClientResponse,
signal: AbortSignal | undefined,
useDefaultTimeout = true,
): Promise<Response> {
const requestSignal = useDefaultTimeout
? signal === undefined
? AbortSignal.timeout(this.timeoutMs)
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
: signal
const response = await this.doFetch(new URL(path, this.resolveBase()), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: signal === undefined ? timeout : AbortSignal.any([timeout, signal]),
...requestSignal === undefined ? {} : { signal: requestSignal },
})
if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
return response
@@ -198,10 +209,11 @@ export abstract class AbstractApiClient implements IApiClient {
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
useDefaultTimeout = true,
): Promise<RpcResponse<ResponseValue<K>>> {
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
this.onEnvelope(message)
const response = await this.postJson(`/api/${method}`, message, signal)
const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout)
const full = serverResponseSchema.parse(await response.json())
this.onEnvelope(full)
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
@@ -282,6 +294,9 @@ export abstract class AbstractApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
// A native system dialog is user-paced and may legitimately stay open
// longer than the normal unary deadline. Caller/connection aborts remain.
pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false),
}
readonly workspace: IApiClient['workspace'] = {
+2 -1
View File
@@ -21,7 +21,7 @@ import {
sessionListRequestSchema,
sessionPromptRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
import { hostDescribeRequestSchema, hostPickDirectoryRequestSchema } from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
workspaceDeleteRequestSchema,
@@ -55,6 +55,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
@@ -0,0 +1,136 @@
/** Cross-platform native single-directory picker used by the local GUI carrier. */
import { execFile } from 'node:child_process'
/** Testable command boundary; native implementations never invoke a shell. */
export type DirectoryPickerRunner = (
command: string,
args: readonly string[],
signal: AbortSignal,
) => Promise<{ stdout: string; stderr: string }>
/** Injectable platform facts for deterministic adapter tests. */
export interface DirectoryPickerInternals {
platform?: NodeJS.Platform
run?: DirectoryPickerRunner
}
const runCommand: DirectoryPickerRunner = (command, args, signal) =>
new Promise((resolve, reject) => {
execFile(
command,
[...args],
{ encoding: 'utf8', signal, windowsHide: true },
(error, stdout, stderr) => {
if (error !== null) {
const failure = Object.assign(new Error(error.message, { cause: error }), {
code: error.code,
stdout,
stderr,
})
reject(failure)
return
}
resolve({ stdout, stderr })
},
)
})
function outputPath(stdout: string): string | null {
const path = stdout.replace(/[\r\n]+$/, '')
return path === '' ? null : path
}
function errorCode(error: unknown): string | number | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined
const code = (error as { code?: unknown }).code
return typeof code === 'string' || typeof code === 'number' ? code : undefined
}
function errorStderr(error: unknown): string {
if (typeof error !== 'object' || error === null || !('stderr' in error)) return ''
const stderr = (error as { stderr?: unknown }).stderr
return typeof stderr === 'string' ? stderr : ''
}
function isMissingCommand(error: unknown): boolean {
return errorCode(error) === 'ENOENT'
}
function rethrowIfAborted(signal: AbortSignal, error: unknown): void {
if (signal.aborted) throw error
}
/**
* Open the platform directory picker.
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
* @returns the selected path, or null when the user cancels.
*/
export async function pickNativeDirectory(
signal: AbortSignal,
internals: DirectoryPickerInternals = {},
): Promise<string | null> {
const platform = internals.platform ?? process.platform
const run = internals.run ?? runCommand
if (platform === 'darwin') {
try {
const result = await run('osascript', [
'-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
'-e', 'POSIX path of selectedFolder',
], signal)
return outputPath(result.stdout)
} catch (error: unknown) {
if (!signal.aborted && errorCode(error) === 1
&& /(?:User canceled|-128)/i.test(errorStderr(error))) return null
throw error
}
}
if (platform === 'win32') {
const script = [
"$ErrorActionPreference = 'Stop'",
'Add-Type -AssemblyName System.Windows.Forms',
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
"$dialog.Description = 'Select Workspace Directory'",
'$dialog.ShowNewFolderButton = $true',
'$result = $dialog.ShowDialog()',
'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {',
' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
' [Console]::WriteLine($dialog.SelectedPath)',
'}',
].join('; ')
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
return outputPath(result.stdout)
}
if (platform === 'linux') {
try {
const result = await run('zenity', [
'--file-selection', '--directory', '--title=Select Workspace Directory',
], signal)
return outputPath(result.stdout)
} catch (error: unknown) {
rethrowIfAborted(signal, error)
if (errorCode(error) === 1) return null
if (!isMissingCommand(error)) throw error
}
try {
const result = await run('kdialog', [
'--getexistingdirectory', '.', '--title', 'Select Workspace Directory',
], signal)
return outputPath(result.stdout)
} catch (error: unknown) {
rethrowIfAborted(signal, error)
if (errorCode(error) === 1) return null
if (isMissingCommand(error)) {
throw new Error('no supported native directory picker found (install zenity or kdialog)')
}
throw error
}
}
throw new Error(`native directory picker is unsupported on ${platform}`)
}
@@ -57,6 +57,7 @@ function stubAgent(session: Session): Agent {
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
pickDirectory?: (signal: AbortSignal) => Promise<string | null>,
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -96,10 +97,33 @@ async function harness(
model: 'test-model',
cwd: workspaceRoot,
workspaceRoot,
...pickDirectory === undefined ? {} : { pickDirectory },
})
return { api, ctx, storageDomain, workspaceRoot }
}
describe('host.pickDirectory', () => {
it('returns a selected path or explicit cancellation from the injected native boundary', async () => {
const selected = await harness(undefined, async () => '/tmp/project')
expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: '/tmp/project' } })
const cancelled = await harness(undefined, async () => null)
expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: null } })
})
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
const { api } = await harness(undefined, signal => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}))
const abort = new AbortController()
const pending = api.host.pickDirectory(request({}), abort.signal)
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
})
describe('workspace.create', () => {
it('serializes concurrent names and rejects the duplicate', async () => {
const { api, workspaceRoot } = await harness()
@@ -131,6 +155,13 @@ describe('workspace.create', () => {
expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } })
expectOk(await api.workspace.rename(request({
workspaceId: first.workspace.workspaceId,
title: 'renamed-existing',
})))
const reopened = expectOk(await api.workspace.create(request({ path: existing })))
expect(reopened.workspace.title).toBe('renamed-existing')
const missing = join(workspaceRoot, 'missing')
const missingResult = await api.workspace.create(request({ path: missing }))
expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
@@ -141,6 +172,20 @@ describe('workspace.create', () => {
expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
}
})
it('rejects different paths that derive the same Workspace title', async () => {
const { api, workspaceRoot } = await harness()
const first = join(workspaceRoot, 'one', 'project')
const second = join(workspaceRoot, 'two', 'project')
mkdirSync(first, { recursive: true })
mkdirSync(second, { recursive: true })
expectOk(await api.workspace.create(request({ path: first })))
const conflict = await api.workspace.create(request({ path: second }))
expect(conflict.result).toMatchObject({
ok: false,
error: { code: 'workspace-name-conflict', details: { name: 'project' } },
})
})
})
describe('session creation and Workspace membership', () => {
@@ -35,7 +35,11 @@ function scriptedApi(overrides: {
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,
},
host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host },
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
pickDirectory: r => ok(r, { path: null }),
...overrides.host,
},
workspace: {
list: r => ok(r, { items: [] }),
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
@@ -47,6 +47,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
},
async pickDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }
},
},
workspace: {
async list(request) {
@@ -108,8 +111,8 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
}
}
function client(api: ApiProxy = fakeApi()): InProcessApiClient {
return new InProcessApiClient(toFetchHandler(api))
function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClient {
return new InProcessApiClient(toFetchHandler(api), timeoutMs)
}
async function collect<F>(stream: AsyncIterable<RpcRequest<F>>): Promise<RpcRequest<F>[]> {
@@ -145,6 +148,16 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect((await c.host.describe({})).result.ok).toBe(true)
})
it('round-trips the native picker without the default unary timeout', async () => {
const api = fakeApi()
api.host.pickDirectory = async (request) => {
await new Promise(resolve => setTimeout(resolve, 15))
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/project' } } }
}
const response = await client(api, 1).host.pickDirectory({})
expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } })
})
it('round-trips command.list / command.execute / skill.list through the wire form', async () => {
const c = client()
const list = await c.commands.list({ sessionId: 's' as never })
@@ -170,6 +183,30 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(parsed.rpcId).toBe('r-sig')
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into host.pickDirectory', async () => {
const api = fakeApi()
api.host.pickDirectory = async (request, signal) => {
if (!signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
return {
rpcId: request.rpcId,
result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } },
}
}
const handler = toFetchHandler(api)
const controller = new AbortController()
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} })
const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', {
method: 'POST', body, signal: controller.signal,
}))
controller.abort()
const parsed = await (await pending).json() as { result: { error?: { code: string } } }
expect(parsed.result.error?.code).toBe('cancelled')
})
})
describe('handler carrier-layer statuses', () => {
@@ -0,0 +1,139 @@
type ExecFileCallback = (
error: (Error & { code?: string | number }) | null,
stdout: string,
stderr: string,
) => void
type ExecFileMock = (
command: string,
args: readonly string[],
options: { encoding: string; signal: AbortSignal; windowsHide: boolean },
callback: ExecFileCallback,
) => void
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() }))
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { describe, expect, it, vi } from 'vitest'
import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts'
function failure(code: string | number, stderr = ''): Error {
return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr })
}
const signal = () => new AbortController().signal
describe('native directory picker', () => {
it('uses the macOS folder chooser and maps user cancellation to null', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBe('/Users/test/project/')
expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal))
run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)'))
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull()
run.mockRejectedValueOnce(failure(2, 'permission denied'))
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toThrow('command failed')
})
it.each([
['a primitive error', 'failed'],
['an invalid code type', { code: true }],
['a missing stderr property', { code: 1 }],
['a non-string stderr property', { code: 1, stderr: 42 }],
])('does not mistake %s for macOS cancellation', async (_label, reason) => {
const run = vi.fn<DirectoryPickerRunner>(async () => { throw reason })
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
})
it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project')
expect(run).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
expect.any(AbortSignal),
)
expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'")
run.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull()
run.mockRejectedValueOnce(failure(1, 'Add-Type failed'))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
})
it('runs the default command adapter without a shell and preserves command failures', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, 'C:\\work\\default\r\n', '')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default')
const [command, args, options] = execFileMock.mock.calls[0]!
expect(command).toBe('powershell.exe')
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
expect(options.encoding).toBe('utf8')
expect(options.windowsHide).toBe(true)
expect(options.signal).toBeInstanceOf(AbortSignal)
const commandError = Object.assign(new Error('powershell failed'), { code: 7 })
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(commandError, 'partial output', 'failure details')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({
message: 'powershell failed', cause: commandError, code: 7,
stdout: 'partial output', stderr: 'failure details',
})
})
it('uses the current process platform when no platform override is supplied', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform')
})
it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {
const run = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project')
expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog'])
const zenity = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/home/test/direct\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenity }))
.resolves.toBe('/home/test/direct')
expect(zenity).toHaveBeenCalledOnce()
})
it('maps Linux cancellation to null and reports a missing desktop picker', async () => {
const cancelled = vi.fn<DirectoryPickerRunner>(async () => { throw failure(1) })
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: cancelled })).resolves.toBeNull()
const missing = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ENOENT') })
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing }))
.rejects.toThrow('install zenity or kdialog')
const kdialogCancelled = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockRejectedValueOnce(failure(1))
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogCancelled }))
.resolves.toBeNull()
const zenityFailed = vi.fn<DirectoryPickerRunner>(async () => { throw failure(2) })
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenityFailed }))
.rejects.toThrow('command failed')
const kdialogFailed = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockRejectedValueOnce(failure(2))
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogFailed }))
.rejects.toThrow('command failed')
})
it('does not convert caller aborts into user cancellation', async () => {
const abort = new AbortController()
abort.abort(new Error('closed'))
const run = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ABORT_ERR') })
await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed')
})
it('reports unsupported platforms', async () => {
await expect(pickNativeDirectory(signal(), { platform: 'aix' })).rejects.toThrow('unsupported on aix')
})
})
@@ -66,6 +66,10 @@ async function harness(
return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox }
}
// PtySendOperation.append drops output once the operation settles, so this only
// observes a marker the child prints while `operation` is still active. A caller
// whose child is slow to print must raise the harness `timing` bounds too;
// extending this deadline alone cannot recover output the operation never collected.
async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs
let output = ''
@@ -191,7 +191,7 @@ function resolveRoute(
function systemPrompt(config: ResolvedSessionTitleLlmConfig): string {
return [
'Create a concise title for an AI coding-assistant session from the supplied human messages.',
'Return only the title on one line, with no quotes, prefix, explanation, Markdown, or terminal control codes.',
'Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.',
'Use the language of the messages.',
`Aim for about ${config.targetWords} words in non-CJK languages or ${config.targetCjkCharacters} CJK characters.`,
].join('\n')
+6
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/telemetry/README.md
README.md: 944cb3f9bac6169feddf8b49bc481cfbe7c6fa9d
README.zh.md: 7be8af93654565cd9fb4d8b4376e0960fd7eb72b
+10
View File
@@ -0,0 +1,10 @@
# telemetry/
English | [中文](README.zh.md)
Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the `telemetry/record` waterfall (deployment-mounted redaction rules; the seam ships none), the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md).
| Package | Role |
|---|---|
| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, handoff cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). |
| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: the OTel JS SDK's log pipeline (`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP exporter), configured verbatim through passthroughs. |
+10
View File
@@ -0,0 +1,10 @@
# telemetry/
[English](README.md) | 中文
面向外部的会话上报:遥测(telemetryseam 及其 OpenTelemetry 后端。整套设计归档于[复活 Agent Noteagent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md):边界公理(harness 的职责止于 `emit()`,投递由上报 SDK 负责)、`telemetry/record` waterfall(瀑布式事件;脱敏规则由部署方挂载,seam 自身不带任何规则)、固定分片投影、handoff 游标,以及运维记录通道。
| 包 | 职责 |
|---|---|
| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | seam 本体:捕获点、投影、脱敏、handoff 游标、运维信号,以及最小后端契约(`emit`/`flush?`/`shutdown`)。 |
| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | 部署方要加载的后端:OTel JS SDK 的日志流水线(`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP 导出器),经透传(passthrough)原样配置。 |
@@ -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/telemetry/session-telemetry-otel/README.md
README.md: 9b208e291e77bee50d9d4fd14808268dca75f2db
README.zh.md: f36cfe74146b779c4ddb1101227cb45a06f0968c
@@ -0,0 +1,41 @@
# @deepseek-ai/dsh-session-telemetry-otel
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.
## Config
```yaml
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
headers:
authorization: !!js `Bearer ${process.env.OTLP_TOKEN}`
processor: {} # optional; passed verbatim to BatchLogRecordProcessor
```
`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load (as does a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown). Everything else is the SDK's option shape, owned and documented by the SDK, and both 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 the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag.
## 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.
## 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.
## Model Experience
None, as the backend only forwards the seam's redacted records into the OTel SDK pipeline; it never contributes to a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **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.
- **No live-collector coverage** — every test exports to a local mock collector; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape on every run, and behavior against a real OTLP deployment (auth, TLS, throttling) is the SDK exporter's documented territory.
@@ -0,0 +1,41 @@
# @deepseek-ai/dsh-session-telemetry-otel
[English](README.md) | 中文
[遥测(telemetryseam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。它原样组合 OTel JS SDK(`LoggerProvider``BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把 seam 交接过来的每条记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份(`service.name`/`service.version`)来自 `dsh-llm``APP_IDENTITY`,与归因标头同源。
## 配置
```yaml
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
headers:
authorization: !!js `Bearer ${process.env.OTLP_TOKEN}`
processor: {} # optional; passed verbatim to BatchLogRecordProcessor
```
`exporter.url` 是本包唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败(`processor.maxExportBatchSize` 不是正整数时同样如此:SDK 会接受该值,随后却在关闭时因它挂起)。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers``timeoutMillis``compression``keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。
## 哪些数据会离开本机
记录携带完整的 `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 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。
## 字段映射
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 脱敏后的记录转发进 OTel SDK 流水线;它绝不向模型请求贡献任何内容。
#### KV Cache 影响
无;本包既不组装也不发送提供方请求。
## 已知限制与延期工作
- **上游实验性源码树**`@opentelemetry/sdk-logs` 仍从上游实验性(experimental)源码树发布;SDK API 的变动只会落在本包,也仅落在本包;seam 契约不动。
- **无真实 collector 覆盖**:所有测试都导出到本地 mock collector;无密钥的 Loader 组合 e2e`tests/loader-composition.e2e.ts`)在每次运行中都覆盖协议格式(wire format)形态,而面对真实 OTLP 部署的行为(认证、TLS、限流)属于 SDK 导出器文档的职责范围。
@@ -0,0 +1,53 @@
{
"name": "@deepseek-ai/dsh-session-telemetry-otel",
"description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline",
"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",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.220.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
"@opentelemetry/otlp-exporter-base": "^0.220.0",
"@opentelemetry/resources": "^2.9.0",
"@opentelemetry/sdk-logs": "^0.220.0",
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-telemetry": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,181 @@
/**
* OpenTelemetry backend for the DeepSeek Harness telemetry seam.
*
* Composes the OTel JS SDK as-is — a `LoggerProvider` with a
* `BatchLogRecordProcessor` and an OTLP/HTTP log exporter — and maps each
* 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; this package
* adds no knobs of its own on top of them.
*
* @module @deepseek-ai/dsh-session-telemetry-otel
*/
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 { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
import {
BatchLogRecordProcessor,
LoggerProvider,
type BatchLogRecordProcessorOptions,
} from '@opentelemetry/sdk-logs'
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'
import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs'
import { resourceFromAttributes } from '@opentelemetry/resources'
// The package's own manifest is the single source of the instrumentation-scope
// version (same pattern as dsh-llm's attribution identity).
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
/**
* Plugin configuration: two verbatim SDK option shapes plus nothing else.
* `exporter.url` is the one field this package validates itself — required,
* no default, must parse as an `http(s)` URL — because a missing endpoint
* must fail at plugin load, not at first export.
*/
export interface Config {
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
* `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
* `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
* 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. */
url?: string
}
/**
* Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
* which this plugin fills); the SDK owns and documents these knobs.
*/
processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
}
/**
* Schemastery validator for {@link Config}; cordis runs it before the plugin
* starts. Shape-level only — the load-bearing `exporter.url` check lives in
* the constructor so its error message names the field. Both slots are opaque
* passthroughs: the SDK owns their shapes and validates its own options;
* re-declaring them field-by-field here would violate the boundary axiom
* (and silently drop every field not re-declared).
*/
export const Config: z<Config> = z.object({
exporter: z.any(),
processor: z.any(),
})
/** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */
const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
warn: { severityNumber: SeverityNumber.WARN, severityText: 'WARN' },
error: { severityNumber: SeverityNumber.ERROR, severityText: 'ERROR' },
}
/**
* 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.
*/
export class TelemetryOtel extends Telemetry {
static inject = ['sessions']
static Config = Config
private readonly provider: LoggerProvider
private readonly ledger: Logger
private readonly ops: Logger
constructor(ctx: Context, config: Config) {
super(ctx)
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)')
}
let parsed: URL
try {
parsed = new URL(url)
} catch {
// Re-thrown as a config error: the only way here is a malformed url string.
throw new Error(`session-telemetry-otel: exporter.url is not a valid URL: ${JSON.stringify(url)}`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`)
}
// The one processor field checked beyond the SDK's own validation: the
// SDK accepts a non-positive batch size, but its shutdown drain then
// splices empty batches without consuming the queue — dispose would hang
// forever with records queued. Misconfiguration fails at load instead.
const batchSize = config.processor?.maxExportBatchSize
if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) {
throw new Error(`session-telemetry-otel: processor.maxExportBatchSize must be a positive integer, got ${String(batchSize)}`)
}
this.provider = new LoggerProvider({
resource: resourceFromAttributes({
'service.name': APP_IDENTITY.product,
'service.version': APP_IDENTITY.version,
}),
processors: [
new BatchLogRecordProcessor({
...config.processor,
// The complete validated exporter object, verbatim: every SDK
// option (`timeoutMillis`, `compression`, `keepAlive`, …) reaches
// the exporter — rebuilding selected fields here would silently
// ignore the rest. App identity travels in the Resource
// (service.name/version); the transport-level user-agent is the
// SDK's own, per the axiom.
exporter: new OTLPLogExporter(config.exporter),
}),
],
})
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)
}
/**
* 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.
*/
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,
})
}
// The seam's optional flush() hint is deliberately NOT implemented. The
// batch processor exports on its own cadence (`processor.scheduledDelayMillis`,
// the SDK's documented knob), and this backend is the SDK pipeline's only
// caller — forwarding the hint to `forceFlush()` was the sole source of
// concurrent flushes, whose undocumented interactions with shutdown's
// internal drain (concurrent-flush guard, provider-level flush timeout)
// silently dropped tail records. Removal history and the revival trigger:
// the revival Agent Note.
/**
* Delegate disposal to the SDK's shutdown contract: drain the queue and
* quiesce. With no concurrent `forceFlush()` in the process (see above),
* shutdown's internal drain is complete — everything emitted before this
* call, including the coordinator's dispose-time `shutdown` markers, is
* exported before the exporter closes. Awaited (and error-contained) by
* the coordinator's disposer.
* @returns resolves when the SDK pipeline has quiesced.
*/
shutdown(): Promise<void> {
return this.provider.shutdown()
}
}
export default TelemetryOtel
@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry-otel`.
* @module @deepseek-ai/dsh-session-telemetry-otel/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry-otel'
/** Cordis companion plugin name. */
export const name = 'session-telemetry-otel-invariant'
/** Service required before the companion can reserve package ownership. */
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.
*/
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 */
@@ -0,0 +1,99 @@
/**
* REAL-composition tier: boot the examples-owned telemetry Loader fixture as
* a subprocess (per testing policy, through the same app/boot path a
* deployment uses), run one mocked-model turn with a real bash round trip,
* and assert against what the mock OTLP collector actually received on the
* wire: ledger mirroring, the deployment-mounted redact rule applied to the
* exported copy, ops markers, and the untouched canonical log.
*/
import { readFile, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const driver = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts',
import.meta.url,
))
const configPath = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml',
import.meta.url,
))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
const FIXTURE_SECRET = 'sk-e2efixture1234567890'
const FIXTURE_PLACEHOLDER = '[E2E-REDACTED]'
interface OtlpLogRecord {
attributes?: { key: string; value: Record<string, unknown> }[]
body?: unknown
}
interface OtlpCapture {
resourceLogs: {
scopeLogs: {
scope: { name: string }
logRecords: OtlpLogRecord[]
}[]
}[]
}
async function jsonlFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
const paths = await Promise.all(entries.map(async (entry) => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return jsonlFiles(path)
return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
}))
return paths.flat()
}
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 = ''
const { stderr } = await runLoaderSmoke({
label: 'session-telemetry-otel loader smoke',
tempDirPrefix: 'telemetry-otel-e2e-',
binScript: driver,
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')
},
})
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 })))))
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']]
: []) ?? [])
for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) {
expect(eventTypes, 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)
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)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
@@ -0,0 +1,238 @@
/**
* OTel backend unit tier: wire assertions against a scripted `node:http`
* mock collector through the SDK's REAL pipeline (BatchLogRecordProcessor →
* OTLP/HTTP JSON), config fail-loud cases, and the real-Loader-path guard
* for the default-exported Service class.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { createServer, type Server } from 'node:http'
import { once } from 'node:events'
import { gunzipSync } from 'node:zlib'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import TelemetryOtel, { Config } from '../src/index.ts'
interface Capture {
headers: import('node:http').IncomingHttpHeaders
body: OtlpLogsRequest
}
/** Just the slice of ExportLogsServiceRequest JSON these assertions touch. */
interface OtlpLogsRequest {
resourceLogs: {
resource: { attributes: { key: string; value: { stringValue?: string } }[] }
scopeLogs: {
scope: { name: string }
logRecords: {
timeUnixNano: string
severityNumber: number
severityText: string
attributes?: { key: string; value: Record<string, unknown> }[]
}[]
}[]
}[]
}
const servers: Server[] = []
afterEach(async () => {
for (const server of servers.splice(0)) {
server.close()
server.closeAllConnections()
}
})
async function mockCollector(
beforeRespond?: (requestIndex: number) => Promise<void> | void,
): Promise<{ url: string; captures: Capture[] }> {
const captures: Capture[] = []
let requestIndex = 0
const server = createServer((request, response) => {
const chunks: Buffer[] = []
request.on('data', chunk => chunks.push(chunk as Buffer))
request.on('end', () => {
const index = requestIndex++
void (async () => {
await beforeRespond?.(index)
const raw = Buffer.concat(chunks)
const body = request.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw
captures.push({
headers: request.headers,
body: JSON.parse(body.toString()) as OtlpLogsRequest,
})
response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
})()
})
})
servers.push(server)
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return { url: `http://127.0.0.1:${address.port}/v1/logs`, captures }
}
async function boot(url: string) {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
exporter: { url, headers: { authorization: 'Bearer test-token' } },
})
return { ctx, fiber }
}
function allRecords(captures: Capture[]) {
return captures.flatMap(c => c.body.resourceLogs.flatMap(r => r.scopeLogs.flatMap(s =>
s.logRecords.map(record => ({ scope: s.scope.name, record })))))
}
describe('TelemetryOtel wire', () => {
it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => {
const { url, captures } = await mockCollector()
const { ctx, fiber } = await boot(url)
const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
await fiber.dispose()
expect(captures.length).toBeGreaterThan(0)
const first = captures[0]!
const authorization: string | undefined = first.headers.authorization
expect(authorization).toBe('Bearer test-token')
const resource = first.body.resourceLogs[0]!.resource.attributes
expect(resource).toContainEqual({ key: 'service.name', value: { stringValue: 'deepseek-harness' } })
const records = allRecords(captures)
const ledger = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel')
const ops = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops')
const start = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
expect(start).toBeDefined()
expect(start?.record.severityNumber).toBe(9)
expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.events[0]!.time) * 1_000_000n)
expect(start?.record.attributes).toContainEqual({ key: 'session.cwd', value: { stringValue: '/tmp/w' } })
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(ops).toHaveLength(1)
expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
})
it('drains records enqueued after a timer export began: dispose during an in-flight batch', async () => {
// The backend implements NO flush() — the batch processor exports on its
// own cadence, and shutdown's internal drain is complete exactly because
// nothing in the process calls forceFlush() concurrently (the SDK's
// concurrent-flush guard skips draining otherwise). Pin that: hold the
// collector's response to the timer-triggered export open across
// disposal, and the dispose-time shutdown marker (enqueued after that
// batch's snapshot) must still arrive.
const gate = Promise.withResolvers<boolean>()
const arrived = Promise.withResolvers<boolean>()
const { url, captures } = await mockCollector(async (index) => {
if (index === 0) {
arrived.resolve(true)
await gate.promise
}
})
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
exporter: { url },
processor: { scheduledDelayMillis: 10 },
})
const session = ctx.sessions.create(SessionId('drain'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await arrived.promise
const disposal = fiber.dispose()
// Let disposal reach the backend's shutdown while the export is held open.
await new Promise(resolve => setTimeout(resolve, 50))
gate.resolve(true)
await disposal
const ops = allRecords(captures).filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops')
expect(ops).toHaveLength(1)
expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
})
it('passes exporter options beyond url and headers through to the SDK exporter', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
// `compression` is a documented SDK exporter option; the advertised
// verbatim passthrough must hand it (and every other field) to the
// exporter rather than silently rebuilding url/headers only.
const fiber = await ctx.plugin(TelemetryOtel, {
exporter: { url, compression: 'gzip' },
} as Config)
const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
await fiber.dispose()
expect(captures.length).toBeGreaterThan(0)
expect(captures[0]!.headers['content-encoding']).toBe('gzip')
const types = allRecords(captures).flatMap(({ record }) =>
record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? [])
expect(types).toContain('turn/start')
})
it('maps warn severity from record policy and leaves the seam flush hint unimplemented', async () => {
const { url, captures } = await mockCollector()
const { ctx, fiber } = await boot(url)
ctx.on('telemetry/record', (_record, next) => ({ ...next(), severity: 'warn' }))
const session = ctx.sessions.create(SessionId('warn'), { meta: {} })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
// No flush(): the coordinator's optional-call forwarding no-ops, and the
// batch processor owns export cadence end to end (see the backend note).
expect('flush' in ctx.telemetry && ctx.telemetry.flush !== undefined).toBe(false)
await fiber.dispose()
const start = allRecords(captures).find(r =>
r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
expect(start?.record.severityNumber).toBe(13)
})
})
describe('TelemetryOtel config fails loud', () => {
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\)/],
// 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/],
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
])('rejects %j at plugin load', async (config, message) => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message)
})
})
describe('dsh-session-telemetry-otel real-load-path guard', () => {
it('keeps the Service class with inject/Config through unwrapExports', async () => {
const module = await import('../src/index.ts')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(module) as typeof TelemetryOtel
expect(unwrapped).toBe(TelemetryOtel)
expect(unwrapped.inject).toEqual(['sessions'])
expect(typeof unwrapped.Config).toBe('function')
})
it('boots through the unwrapped class and registers ctx.telemetry', async () => {
const { url } = await mockCollector()
const module = await import('../src/index.ts')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(module) as Parameters<Context['plugin']>[0]
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(unwrapped, { exporter: { url } })
expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel)
await fiber.dispose()
})
})
@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/session"
},
{
"path": "../../llm/llm"
},
{
"path": "../session-telemetry"
},
{
"path": "../../support/invariants"
}
]
}
@@ -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/telemetry/session-telemetry/README.md
README.md: 4905b4492b3c7266c2d18c32302782d1185d80b5
README.zh.md: dd70f6274878baa95c0c224c7fd9aff861a8ca32
@@ -0,0 +1,42 @@
# @deepseek-ai/dsh-session-telemetry
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 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.
## 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; turn-enclosure structurally bars those errors from the log), 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`).
## 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.
## 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.
## The fixed chunk projection
Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are dropped at capture and never advance the cursor. That one chunk is the stream-started signal: `step/start` + first-chunk presence + `assistant/message` presence + the `turn/end` reason distinguish "the request never started" from "the stream died midway" without chunk volume, and time-to-first-token stays computable. Chunk elision makes `seq` gaps routine on the wire — a gap is never a loss signal. Every other event type, including ones merged by plugins this package never heard of, passes through whole.
## The logical record
`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError`, `turn/end` error reasons, and `agent-error`; INFO for other captured records, while `telemetry/record` policies may assign WARN), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id`/`session.seed_length` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum; `agent-error` normalizes its arbitrary thrown value into a stable `{ name, message }` body. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`.
## Model Experience
None, as the seam only observes the session stream and hands redacted copies to a reporting backend; it never contributes to a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **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.
@@ -0,0 +1,42 @@
# @deepseek-ai/dsh-session-telemetry
[English](README.md) | 中文
遥测(telemetry)seam:会话事件上报的捕获侧,隔在一个后端契约之后,任何上报 SDK 都无需变形即可满足该契约。塑造本包一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
## 后端契约
`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端在其构造函数中组合 `TelemetryCoordinator`
## 捕获点
协调器的全部注册都经由组合方 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`)。
## 脱敏 waterfall
每条记录在投影与 `emit()` 之间都要经过 `telemetry/record` waterfall(瀑布式事件),这是该 seam 的擦除扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式扣下这一条记录。脱敏只作用于导出副本;权威会话日志永不改写。
## 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」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。
## 固定分片投影
每个 `(turn, step)` 只发出第一条 `assistant/chunk`;其余分片在捕获时丢弃,且绝不推进游标。这一条分片就是「流已开始」的信号:`step/start`、首分片是否存在、`assistant/message` 是否存在,加上 `turn/end` 的原因,无需分片流量即可区分「请求从未开始」与「流中途夭折」,首个 token 延迟(time-to-first-token)也仍然可以计算。分片省略使导出流中的 `seq` 缺口成为常态:缺口绝不是丢失信号。其余所有事件类型都会完整透传,包括本包从未听说过的插件所合并的事件类型。
## 逻辑记录
`TelemetryRecord` 包含:`channel``ledger` | `ops`)、`time`epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError``turn/end` 的错误原因与 `agent-error` 映射为 ERROR,其他已捕获记录映射为 INFO,而 `telemetry/record` 策略可以指定 WARN)、只含身份信息的 `attributes``session.id``event.type``event.seq`header 中存在时再加 `session.cwd`/`session.parent_id`/`session.seed_length`),以及作为 `body` 的完整深拷贝 `event.data`,且以脱敏后的内容为准。运维记录携带 `telemetry.op``agent-error` | `shutdown`)和 `session.id`,并刻意不带 `event.seq`/`event.type`:它们是用来告警的信号,不是用来累加的条目;`agent-error` 会把任意抛出值规范化为稳定的 `{ name, message }` 记录主体。交接之后的投递由后端 SDK 负责;重复仍然可能出现(无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。
## 模型体验
无。该 seam 只观察会话流,并把脱敏后的副本交给上报后端;它绝不向模型请求贡献任何内容。
#### KV Cache 影响
无;本包既不组装也不发送提供方请求。
## 已知限制与延期工作
- **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。
- **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。
@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-session-telemetry",
"description": "Telemetry seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend",
"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",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,285 @@
/**
* 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
* `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.
*
* @module @deepseek-ai/dsh-session-telemetry/coordinator
*/
import type { Context } from 'cordis'
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'
/**
* The handoff cursor: per session, the highest `seq` handed to a backend.
* Deliberately MODULE-scope ambient state — a narrow, documented exception
* to the registrations-are-effects discipline: cordis has no HMR
* state-handover API, and keying by the `Session` object (which belongs to
* the session store and outlives any telemetry fiber) is the only in-process
* lifetime that lets a re-adopting fiber resume instead of re-handing
* history. Entries die with their sessions; a missing entry safely means
* "re-hand everything". Advanced only at emit time — the cursor marks
* handed-off, not delivered.
*/
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.
*/
export class TelemetryCoordinator {
/**
* Sessions adopted by THIS fiber and still live, for double-adoption
* protection and the teardown sweep of unmarked sessions;
* `session/disposed` marks and retires entries.
*/
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.
*/
constructor(
private readonly ctx: Context,
private readonly backend: TelemetryBackend,
) {
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))
})
})
ctx.on('session/event', (session, event) => {
this.contain(() => {
this.capture(session, event)
})
})
// 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)
})
})
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.
for (const session of this.adopted) {
this.contain(() => {
this.handOff(shutdownRecord(session))
})
}
try {
await this.backend.shutdown()
} catch (error) {
this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`)
}
}, 'telemetry capture')
for (const session of ctx.sessions.list()) {
this.adopt(session)
}
}
/**
* Adopt a session: replay its log THROUGH the projection from the handoff
* cursor, then rely on the firehose for everything after. When no cursor
* survived, replay starts at the session's construction boundary
* (`firstLiveSeq`), not seq 0: constructor seeds never publish on the
* firehose, and their content already left the process under another
* identity — the same id in a previous process (resume) or the parent's
* stream (fork, stitched by receivers via `session.seed_length`). Events
* at or below the start still feed the projection state (first-chunk
* tracking) without being re-handed, so a resumed fiber drops mid-step
* chunk continuations exactly like the fiber that saw the step begin. The
* cost, accepted with the seam's at-most-once stance: a resume no longer
* backfills records a previous process failed to deliver.
* @param session - the live session to adopt; a second adoption is a no-op.
*/
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)
})
}
}
/** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */
private track(session: Session, event: SessionEvent): void {
if (event.type === 'assistant/chunk') {
this.seen(session).add(`${event.data.turn}:${event.data.step}`)
}
}
/** Project one event and hand it to the backend, advancing the cursor on handoff. */
private capture(session: Session, event: SessionEvent): void {
if (event.type === 'assistant/chunk') {
const key = `${event.data.turn}:${event.data.step}`
const seen = this.seen(session)
// Fixed chunk projection: only the first chunk of each (turn, step)
// ships — the stream-started signal; content is byte-complete in the
// step's assembled assistant/message. Dropped chunks do not advance
// the cursor, so re-adoption re-drops them deterministically.
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),
})
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).
*/
private handOff(record: TelemetryRecord): void {
this.backend.emit(this.ctx.waterfall('telemetry/record', record, () => record))
}
/** Forward the turn-end boundary to the backend's optional flush hint. */
private hintFlush(session: Session): void {
if (this.adopted.has(session)) this.backend.flush?.()
}
/** 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,
})
}
/** Lazily create the per-session first-chunk tracking set. */
private seen(session: Session): Set<string> {
let set = this.chunkSeen.get(session)
if (!set) this.chunkSeen.set(session, set = new Set())
return set
}
/**
* Run one capture-side step with its exception contained: cordis `emit`
* is stop-on-throw, so a throwing listener would starve every subscriber
* registered after this plugin — nothing from the backend may escape.
*/
private contain(step: () => void): void {
try {
step()
} catch (error) {
this.ctx.logger.warn(`telemetry: capture step failed: ${String(error)}`)
}
}
}
/**
* Build the per-session clean-exit marker: emitted at the session's own
* disposal edge, or at coordinator dispose for sessions still alive then.
*/
function shutdownRecord(session: Session): TelemetryRecord {
return {
channel: 'ops',
time: Date.now(),
severity: 'info',
attributes: { 'telemetry.op': 'shutdown', 'session.id': String(session.id) },
body: { op: 'shutdown' },
}
}
/** Map an event's own outcome flag to the pre-baked alerting severity. */
function severityOf(event: SessionEvent): TelemetrySeverity {
switch (event.type) {
case 'tool/result':
return event.data.isError ? 'error' : 'info'
case 'turn/end':
return event.data.reason.kind === 'error' ? 'error' : 'info'
default:
// Merge-extensible fall-through (no assertNever): event types this seam
// does not depend on — including plugin-merged ones it never heard of —
// pass through as info; their owners' outcome semantics stay theirs.
return 'info'
}
}
/** Normalize the live bus's arbitrary thrown value into the stable operational-record shape. */
function errorDetail(error: unknown): { name: string; message: string } {
const normalized = error instanceof Error ? error : new Error(String(error))
return { name: normalized.name, message: normalized.message }
}
/** Build the minimal identity attributes: envelope plus self-contained header facts. */
function identityOf(session: Session, event: SessionEvent): Record<string, string | number> {
const attributes: Record<string, string | number> = {
'session.id': String(session.id),
'event.type': event.type,
'event.seq': event.seq,
}
const { cwd, parentSession, seedLength } = session.header
if (cwd !== undefined) attributes['session.cwd'] = cwd
if (parentSession !== undefined) attributes['session.parent_id'] = String(parentSession)
// The durable fork boundary: a forked stream starts here, and its prefix
// lives in the parent's stream — receivers stitch on (parent_id, seed_length).
if (seedLength !== undefined) attributes['session.seed_length'] = seedLength
return attributes
}
@@ -0,0 +1,156 @@
/**
* Telemetry seam for the DeepSeek Harness.
*
* 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
* 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.
*
* @module @deepseek-ai/dsh-session-telemetry
*/
import { Context, Service } from 'cordis'
declare module 'cordis' {
interface Context {
telemetry: Telemetry
}
interface Events {
/**
* 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.
* @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
*/
'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord
}
}
/**
* Severity of a telemetry record, pre-mapped at capture so a receiver can
* alert with zero configuration: `error` for events whose own outcome flag
* says so (`tool/result.isError`, `turn/end` error reasons) and for
* `agent-error` operational records. Captured events otherwise default to
* `info`; `warn` remains available to `telemetry/record` policies and
* backends.
*/
export type TelemetrySeverity = 'info' | 'warn' | 'error'
/**
* One logical record handed to a backend — the seam's whole outbound
* vocabulary. Ledger records mirror session-log events one-to-one;
* operational records (`channel: 'ops'`) carry the two signals with no log
* home (`agent-error`, `shutdown`) and deliberately omit `event.seq`-style
* identity so they can never be mistaken for ledger rows.
*/
export interface TelemetryRecord {
/** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */
channel: 'ledger' | 'ops'
/** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */
time: number
/** Pre-mapped alerting severity; see {@link TelemetrySeverity}. */
severity: TelemetrySeverity
/**
* Identity attributes, deliberately minimal: ledger records carry
* `session.id`, `event.type`, `event.seq`, plus `session.cwd` /
* `session.parent_id` when the header has them; ops records carry
* `telemetry.op`, `session.id`, and (for `agent-error`) `agent.id`,
* `turn`, `step`, `error.name`. Anything recoverable from the body is
* intentionally NOT duplicated here.
*/
attributes: Record<string, string | number>
/**
* The complete payload: a deep copy of the session event's `data` for
* ledger records (JSON-serializable by `Session.append`'s own
* validation), or the op payload for ops records. Never mutated after
* handoff.
*/
body: unknown
}
/**
* The backend contract the coordinator hands records to — the minimum any
* reporting SDK satisfies with zero bending. {@link Telemetry} is its
* service-registered form; tests compose the coordinator with a bare
* implementation of this interface.
*/
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.
* @param record - the logical record to report; owned by the backend after the call.
*/
emit(record: TelemetryRecord): void
/**
* Optional hint that a natural boundary (turn end) passed — a backend may
* forward it to its SDK's flush so records land at turn boundaries. Called
* fire-and-forget; implementations must not block and must not throw
* meaningfully (the coordinator contains exceptions). Most backends should
* leave this unimplemented and let their SDK's own batching cadence govern
* export timing: a backend that does implement it owns the interaction
* between its concurrent flushes and {@link shutdown}'s drain (the OTel
* backend removed its implementation for exactly that hazard — see the
* revival Agent Note).
*/
flush?(): void
/**
* Forward the fiber's disposal to the SDK: flush whatever is queued and
* reach quiescence, per the SDK's own shutdown contract. Everything
* emitted before this call must still be delivered — including records
* enqueued while a {@link flush} hint is in flight, so a backend whose SDK
* guards against concurrent flushes orders behind the outstanding one (the
* 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.
* @returns resolves when the backend's pipeline has quiesced.
*/
shutdown(): Promise<void>
}
/**
* The backend contract in its loadable form: one implementation per context —
* the cordis `Service` registration under the `telemetry` key throws on a
* duplicate, cordis' standard behavior. A backend composes a
* {@link TelemetryCoordinator} in its constructor to install the capture side.
*/
export abstract class Telemetry extends Service implements TelemetryBackend {
constructor(ctx: Context) {
super(ctx, 'telemetry')
}
/**
* See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home.
* @param record - the logical record to report; owned by the backend after the call.
*/
abstract emit(record: TelemetryRecord): void
/** See {@link TelemetryBackend.flush}. */
flush?(): void
/**
* See {@link TelemetryBackend.shutdown}.
* @returns resolves when the backend's pipeline has quiesced.
*/
abstract shutdown(): Promise<void>
}
export { TelemetryCoordinator } from './coordinator.ts'

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