From cf2e18411244f837999bcf934ba383a21ec8661d Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 03:06:57 +0800 Subject: [PATCH 01/22] feat(telemetry): session-telemetry seam with mandatory redaction + OTel backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revive the reviewed session-telemetry packages from the closed session-telemetry-otlp-rfc branch (PR #222/#231) on current master, renamed to @deepseek-ai/dsh-session-telemetry{,-otel} (the SDK component-telemetry package holds the dsh-telemetry name). Delta over the branch version: every record now passes a telemetry/redact waterfall between projection and emit() — the innermost next() applies a non-configurable conservative credential-shape rule set, listeners stack stricter rules, a throwing rule withholds the record fail-closed, and the canonical log is never rewritten. This answers the export-side concern that closed PR #222; the boundary axiom (our aspect ends at emit(); delivery is the reporting SDK's) is unchanged, and the runtime-telemetry RFC's outbox / readCommitted lane is recorded as deferred in the Agent Note. Covered by seam/redact/OTel-wire unit tiers (100% per-file) and a keyless Loader-composition e2e that boots the examples fixture against a mock OTLP collector and pins redaction on the wire plus the untouched canonical log. --- ...3-session-telemetry-otel-revival.i18n.yaml | 6 + ...26-07-23-session-telemetry-otel-revival.md | 33 ++ ...07-23-session-telemetry-otel-revival.zh.md | 33 ++ docs/capability-seams.md | 6 + docs/config-catalog.md | 32 ++ docs/cordis-catalog/events.md | 27 ++ docs/cordis-catalog/services.md | 23 ++ docs/event-producer-consumer.md | 9 +- docs/module-graph.md | 13 + .../tests/fixtures/telemetry-otel-driver.ts | 43 +++ .../tests/fixtures/telemetry-otel.cordis.yml | 23 ++ examples/package.json | 1 + knip.json | 11 + packages/README.md | 7 +- .../cordis/tool-cordis/src/api-catalog.ts | 33 ++ packages/telemetry/README.md | 8 + .../session-telemetry-otel/README.md | 39 +++ .../session-telemetry-otel/package.json | 52 +++ .../session-telemetry-otel/src/index.ts | 168 ++++++++++ .../session-telemetry-otel/src/invariant.ts | 32 ++ .../tests/loader-composition.e2e.ts | 98 ++++++ .../session-telemetry-otel/tests/otel.e2e.ts | 25 ++ .../session-telemetry-otel/tests/otel.spec.ts | 166 ++++++++++ .../session-telemetry-otel/tsconfig.json | 33 ++ .../telemetry/session-telemetry/README.md | 40 +++ .../telemetry/session-telemetry/package.json | 41 +++ .../session-telemetry/src/coordinator.ts | 244 ++++++++++++++ .../telemetry/session-telemetry/src/index.ts | 145 ++++++++ .../session-telemetry/src/invariant.ts | 32 ++ .../telemetry/session-telemetry/src/redact.ts | 77 +++++ .../session-telemetry/tests/redact.spec.ts | 157 +++++++++ .../session-telemetry/tests/telemetry.spec.ts | 313 ++++++++++++++++++ .../telemetry/session-telemetry/tsconfig.json | 27 ++ pnpm-lock.yaml | 200 +++++++++++ scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 10 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 2 + tsconfig.host.json | 2 + 39 files changed, 2207 insertions(+), 7 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md create mode 100644 examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts create mode 100644 examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml create mode 100644 packages/telemetry/README.md create mode 100644 packages/telemetry/session-telemetry-otel/README.md create mode 100644 packages/telemetry/session-telemetry-otel/package.json create mode 100644 packages/telemetry/session-telemetry-otel/src/index.ts create mode 100644 packages/telemetry/session-telemetry-otel/src/invariant.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.spec.ts create mode 100644 packages/telemetry/session-telemetry-otel/tsconfig.json create mode 100644 packages/telemetry/session-telemetry/README.md create mode 100644 packages/telemetry/session-telemetry/package.json create mode 100644 packages/telemetry/session-telemetry/src/coordinator.ts create mode 100644 packages/telemetry/session-telemetry/src/index.ts create mode 100644 packages/telemetry/session-telemetry/src/invariant.ts create mode 100644 packages/telemetry/session-telemetry/src/redact.ts create mode 100644 packages/telemetry/session-telemetry/tests/redact.spec.ts create mode 100644 packages/telemetry/session-telemetry/tests/telemetry.spec.ts create mode 100644 packages/telemetry/session-telemetry/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml new file mode 100644 index 0000000000..31a5914d0d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-session-telemetry-otel-revival.md: 1150d363e9db98a39e1188b86dc41fa291edba41 +2026-07-23-session-telemetry-otel-revival.zh.md: 76749a59c38ef2b8c5b3f09960ca500c25d8e1a8 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md new file mode 100644 index 0000000000..1150d363e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -0,0 +1,33 @@ +# 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 nothing crosses the seam unredacted: + +- **`@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/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the innermost `next()` applies a conservative built-in rule set (credential shapes: API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo), deployments stack stricter rules as listeners, and a throwing rule withholds the record fail-closed. The pattern list is a security invariant, deliberately not configurable. 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. + +**Export without built-in redaction, delegating to receiver-side collector processors.** Rejected — this is what legal declined. Receiver-side redaction ships the secret first and scrubs it second; the seam must scrub before bytes leave the process, and a waterfall makes the redaction point auditable and stackable. + +**A configurable pattern list for the default rules.** Rejected: deployment-varying tunables belong in config, but a security invariant does not — weakening the floor should require code, not YAML. Stricter rules stack as `telemetry/redact` 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. + +## 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. Credential-shaped substrings never leave the process even on a rule-free deployment, at the cost of a synchronous per-record scrub on the capture path (string-regex over lossless-JSON bodies — bounded by event size, no I/O). Exported bodies can differ from canonical log bytes wherever the placeholder landed, 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. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md new file mode 100644 index 0000000000..76749a59c3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -0,0 +1,33 @@ +# 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 提供能力,部署方配置上报去向,且任何数据未经脱敏不得跨越 seam: + +- **`@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/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;最内层 `next()` 应用保守的内置规则集(凭据形状:API key、GitHub/Slack token、AWS/Google key、JWT、PEM 块、URL userinfo),部署方以监听器堆叠更严规则,抛异常的规则将该记录 fail-closed 扣下。模式列表是安全不变量,刻意不可配置。脱敏只作用于导出副本;canonical log 永不改写。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel 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 SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 + +**不带内置脱敏直接导出,交给接收端 collector processor。** 否决——这正是法务否掉的方案。接收端脱敏是先把秘密发出去再擦除;seam 必须在字节离开进程前擦除,且 waterfall 使脱敏点可审计、可堆叠。 + +**默认规则的模式列表做成可配置。** 否决:随部署变化的调优项应进 config,但安全不变量不应——削弱底线应当需要改代码而非改 YAML。更严格的规则以 `telemetry/redact` 监听器堆叠。 + +**映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 + +## Consequences + +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。即使部署方未配置任何规则,凭据形状的子串也绝不离开进程,代价是捕获路径上每条记录一次同步擦除(对 lossless-JSON body 做字符串正则——受事件大小约束,无 I/O)。导出的 body 在占位符落点处可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a27d325e31..25406c8c9b 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -36,6 +36,9 @@ flowchart LR pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] pkg_acp["acp"] + pkg_session_telemetry["session-telemetry"] + svc_telemetry["ctx.telemetry
Session telemetry seam"] + pkg_session_telemetry_otel["session-telemetry-otel"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] @@ -159,6 +162,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 @@ -276,6 +281,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), [`acp`](../packages/ui/acp), [`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.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..2f55f60bec 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1047,6 +1047,37 @@ 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. */ + exporter?: { + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + url?: string + /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ + headers?: Record + } + /** + * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, + * which this plugin fills); the SDK owns and documents these knobs. + */ + processor?: Omit +} +``` + +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) + +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:39`](../packages/telemetry/session-telemetry-otel/src/index.ts) + ## `@deepseek-ai/dsh-session-title` Requires: `sessions` @@ -1958,6 +1989,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-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ed9bebdc92..4ed5de022b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -803,6 +803,33 @@ 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/redact` — waterfall + +Redact one outbound record before it reaches the backend. The innermost `next()` applies the seam's conservative default rule set (credential-shape scrubbing); listeners stack stricter rules by transforming its return value, and returning without `next()` replaces the default — the exported record is then only as clean as the replacing rule. 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 +/** + * Redact one outbound record before it reaches the backend. The innermost + * `next()` applies the seam's conservative default rule set + * (credential-shape scrubbing); listeners stack stricter rules by + * transforming its return value, and returning without `next()` replaces + * the default — the exported record is then only as clean as the + * replacing rule. 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/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord +``` + +Source: [`packages/telemetry/session-telemetry/src/index.ts:39`](../../packages/telemetry/session-telemetry/src/index.ts) + ## `tools/*` ### `tools/change` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0d6cc97086..22120b2f8e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1533,6 +1533,29 @@ Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-da Source: [`packages/tasks/tasks/src/index.ts:77`](../../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 +``` + +Source: [`packages/telemetry/session-telemetry/src/index.ts:123`](../../packages/telemetry/session-telemetry/src/index.ts) + ## `ctx.tokenMeter` — `TokenMeterService` Replay owner for one service-wide estimator and isolated per-session folds. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b36d390f55..30380261fe 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../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:294`](../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:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -33,16 +33,17 @@ 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:167`](../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:52`](../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:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`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:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`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:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`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:111`](../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) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `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/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:39`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `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) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 212fd723a7..a66c5edc0a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -199,6 +199,10 @@ flowchart TD pkg_tasks["tasks"] 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"] @@ -408,6 +412,9 @@ flowchart TD pkg_tasks --> pkg_invariants pkg_tasks --> pkg_session pkg_tasks --> pkg_timeout + 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 @@ -472,6 +479,10 @@ flowchart TD pkg_pty_local --> pkg_sandbox pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session + 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 @@ -858,6 +869,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), [`timeout`](../packages/util/timeout) | +| [`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) | | [`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) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | @@ -870,6 +882,7 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`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) | +| [`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) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts new file mode 100644 index 0000000000..02be1a9011 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts @@ -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() diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml new file mode 100644 index 0000000000..defde98ff5 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -0,0 +1,23 @@ +# Test-only composition: session-telemetry-otel through the real Loader/app +# path, exporting to the mock OTLP collector the driver starts (url via env). +- id: cli-mock-llm + name: './cli-mock-llm.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 diff --git a/examples/package.json b/examples/package.json index bd87263340..133196aa96 100644 --- a/examples/package.json +++ b/examples/package.json @@ -41,6 +41,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:*", diff --git a/knip.json b/knip.json index 59658e1df6..728ded39b7 100644 --- a/knip.json +++ b/knip.json @@ -31,6 +31,7 @@ "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", "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", @@ -188,6 +189,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" diff --git a/packages/README.md b/packages/README.md index 4e18d7b5f8..93e06631d2 100644 --- a/packages/README.md +++ b/packages/README.md @@ -18,7 +18,7 @@ Packages live at `packages///`; groups are containers, while names r | [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface | | [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | -| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | +| [`compact/`](compact/README.md) | Compaction capability family: abstract seam + basic backend (tool deferred) | Product — stable surface | | [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | @@ -32,8 +32,9 @@ Packages live at `packages///`; groups are containers, while names r | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`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-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, 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 | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | @@ -46,6 +47,6 @@ Groups distinguish product API from support infrastructure. New packages join an The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). -**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface / implementation / consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). +**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface/implementation/consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts). diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c3e4e1858b..aee1ac9ae8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -724,6 +724,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', + 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.', @@ -1102,6 +1120,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/redact', + mode: 'waterfall', + signature: '\'telemetry/redact\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', + jsDoc: '/**\n * Redact one outbound record before it reaches the backend. The innermost\n * `next()` applies the seam\'s conservative default rule set\n * (credential-shape scrubbing); listeners stack stricter rules by\n * transforming its return value, and returning without `next()` replaces\n * the default — the exported record is then only as clean as the\n * replacing rule. Dispatched synchronously on the capture hot path inside\n * the coordinator\'s containment: a throwing listener withholds that one\n * record (fail-closed) and never reaches the agent loop. Redaction\n * applies to the exported copy only; the canonical session log is never\n * 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: 'Redact one outbound record before it reaches the backend.', + }, { name: 'tools/change', mode: 'emit', @@ -2087,6 +2112,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;\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}', diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md new file mode 100644 index 0000000000..a5869077c2 --- /dev/null +++ b/packages/telemetry/README.md @@ -0,0 +1,8 @@ +# telemetry/ + +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 mandatory `telemetry/redact` waterfall, 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. | diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md new file mode 100644 index 0000000000..26edc62645 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-session-telemetry-otel + +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. Everything else is the SDK's option shape, owned and documented by the SDK; batching, retry, queue bounds, and loss policy under sustained failure are its documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. + +## What leaves the machine + +Records carry the seam's REDACTED copy of `event.data` — 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) — after the seam's `telemetry/redact` waterfall has scrubbed credential-shaped substrings (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. A deployment with stricter requirements stacks `telemetry/redact` listeners or opts out structurally. + +## 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 staleness (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). + +## 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. +- **Live-collector smoke is opt-in** — the e2e smoke (`tests/otel.e2e.ts`) self-skips without `$DSH_OTLP_E2E_ENDPOINT`; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape against a mock collector on every run. diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json new file mode 100644 index 0000000000..706112d1fe --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -0,0 +1,52 @@ +{ + "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/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" + } +} diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts new file mode 100644 index 0000000000..4738cdd932 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -0,0 +1,168 @@ +/** + * 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 { 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. */ + exporter?: { + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + url?: string + /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ + headers?: Record + } + /** + * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, + * which this plugin fills); the SDK owns and documents these knobs. + */ + processor?: Omit +} + +/** + * 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. + */ +export const Config: z = z.object({ + exporter: z.object({ + url: z.string(), + headers: z.dict(z.string()), + }), + // Opaque passthrough: the SDK owns this shape and validates its own + // options; re-declaring them here would violate the boundary axiom. + processor: z.any(), +}) + +/** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */ +const SEVERITY: Record = { + 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}`) + } + this.provider = new LoggerProvider({ + resource: resourceFromAttributes({ + 'service.name': APP_IDENTITY.product, + 'service.version': APP_IDENTITY.version, + }), + processors: [ + new BatchLogRecordProcessor({ + ...config.processor, + exporter: new OTLPLogExporter({ + url, + // App identity travels in the Resource (service.name/version); + // the transport-level user-agent is the SDK's own, per the axiom. + // Schemastery fills `headers` with {} before cordis constructs the + // plugin, so the optional type exists for hand-authors only. + headers: config.exporter?.headers as Record, + }), + }), + ], + }) + 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, + }) + } + + /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ + override flush(): void { + // Best-effort hint: the SDK resolves forceFlush even when exports fail + // (failures go to its own diagnostics), and the coordinator stops calling + // this once the fiber is disposed — a rejection would be SDK drift. + /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ + void this.provider.forceFlush().catch(() => {}) + } + + /** + * Delegate disposal to the SDK's shutdown contract: flush the queue and + * quiesce. Awaited (and error-contained) by the coordinator's disposer. + * @returns resolves when the SDK pipeline has quiesced. + */ + shutdown(): Promise { + return this.provider.shutdown() + } +} + +export default TelemetryOtel diff --git a/packages/telemetry/session-telemetry-otel/src/invariant.ts b/packages/telemetry/session-telemetry-otel/src/invariant.ts new file mode 100644 index 0000000000..075e5cc193 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/src/invariant.ts @@ -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 */ diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..2c72fd6170 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -0,0 +1,98 @@ +/** + * 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, default redaction, 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' +import { REDACTION_PLACEHOLDER } from '@deepseek-ai/dsh-session-telemetry' + +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' + +interface OtlpLogRecord { + attributes?: { key: string; value: Record }[] + body?: unknown +} + +interface OtlpCapture { + resourceLogs: { + scopeLogs: { + scope: { name: string } + logRecords: OtlpLogRecord[] + }[] + }[] +} + +async function jsonlFiles(dir: string): Promise { + 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) + + // Default redaction on the wire: the fixture credential never leaves the + // process, its surrounding prose does, and the placeholder marks the spot. + const wire = JSON.stringify(captures) + expect(wire).not.toContain(FIXTURE_SECRET) + expect(wire).toContain(REDACTION_PLACEHOLDER) + expect(wire).toContain('prove telemetry with key') + + // The canonical session log is never rewritten. + expect(logContent).toContain(FIXTURE_SECRET) + expect(logContent).not.toContain(REDACTION_PLACEHOLDER) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts new file mode 100644 index 0000000000..91c92b3486 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts @@ -0,0 +1,25 @@ +/** + * Keyless-self-skipping smoke: ship one real session's records to a live + * OTLP collector named by $DSH_OTLP_E2E_ENDPOINT and require the SDK's + * shutdown (flush-and-quiesce) to resolve. Skipped without the endpoint so + * secretless CI stays green — a CI accommodation, not a cost signal. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TelemetryOtel from '../src/index.ts' + +describe.skipIf(!process.env.DSH_OTLP_E2E_ENDPOINT)('telemetry-otel e2e (live collector)', () => { + it('exports a session and quiesces cleanly', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + exporter: { url: process.env.DSH_OTLP_E2E_ENDPOINT! }, + }) + const session = ctx.sessions.create(SessionId(`e2e-${Date.now()}`), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await expect(fiber.dispose()).resolves.not.toThrow() + }) +}) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts new file mode 100644 index 0000000000..88a09a0f3b --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -0,0 +1,166 @@ +/** + * 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 { 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 }[] + }[] + }[] + }[] +} + +const servers: Server[] = [] + +afterEach(async () => { + for (const server of servers.splice(0)) { + server.close() + server.closeAllConnections() + } +}) + +async function mockCollector(): Promise<{ url: string; captures: Capture[] }> { + const captures: Capture[] = [] + const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + captures.push({ + headers: request.headers, + body: JSON.parse(Buffer.concat(chunks).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('maps the warn severity and forwards the flush hint to the SDK', async () => { + const { url, captures } = await mockCollector() + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('warn'), { meta: {} }) + session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) + // The turn-boundary hint: safe, non-blocking, and enough to push the batch out. + expect(() => { + ctx.telemetry.flush!() + }).not.toThrow() + await fiber.dispose() + const blocked = allRecords(captures).find(r => + r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'prompt/blocked')) + expect(blocked?.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\)/], + ])('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[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() + }) +}) diff --git a/packages/telemetry/session-telemetry-otel/tsconfig.json b/packages/telemetry/session-telemetry-otel/tsconfig.json new file mode 100644 index 0000000000..9512133cf7 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md new file mode 100644 index 0000000000..bccc61fd06 --- /dev/null +++ b/packages/telemetry/session-telemetry/README.md @@ -0,0 +1,40 @@ +# @deepseek-ai/dsh-session-telemetry + +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), and `shutdown()` (the lifecycle forward: flush-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 — seed events from fork/resume never re-emit on the firehose), `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), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per adopted session emit its `shutdown` operational record, 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/redact` waterfall between projection and `emit()` — nothing reaches a backend unredacted. The innermost `next()` applies the built-in conservative rule set (`applyDefaultRedaction`: credential shapes — API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo — replaced with `[REDACTED]` in body strings and string attribute values). Listeners stack stricter rules by transforming `next()`'s return value; returning without `next()` replaces the default rule set, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. The built-in pattern list is a security invariant, deliberately not configurable from cordis.yml. Redaction applies to the exported copy only; the canonical session log is never rewritten. + +## The handoff cursor + +A module-scope `WeakMap` 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 full re-hand, absorbed by receiver-side dedupe on `(session.id, event.seq)`. 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, `compact/end` errors; WARN for `prompt/blocked`; INFO otherwise), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` 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. 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). +- **Redaction is shape-based** — the default rules catch known credential shapes, not every secret; a deployment with stricter needs stacks `telemetry/redact` listeners, and exported data is only as clean as the mounted rules. diff --git a/packages/telemetry/session-telemetry/package.json b/packages/telemetry/session-telemetry/package.json new file mode 100644 index 0000000000..71646c130e --- /dev/null +++ b/packages/telemetry/session-telemetry/package.json @@ -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" + } +} diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts new file mode 100644 index 0000000000..08fbe498b1 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -0,0 +1,244 @@ +/** + * 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/redact` waterfall, and hands the redacted copy 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' +import { applyDefaultRedaction } from './redact.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() + +/** + * 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`). Disposal emits each adopted session's `shutdown` + * operational record 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, for dispose-time `shutdown` records and double-adoption protection. */ + private readonly adopted = new Set() + /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ + private readonly chunkSeen = new WeakMap>() + + /** + * @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) + }) + 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 () => { + 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 (or from the start when no cursor survived), then rely on the + * firehose for everything after. Events at or below the cursor 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. + * @param session - the live session to adopt; a second adoption is a no-op. + */ + private adopt(session: Session): void { + this.contain(() => { + if (this.adopted.has(session)) return + this.adopted.add(session) + const cursor = handoffCursor.get(session) ?? -1 + for (const event of session.events) { + 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/redact` waterfall over one record and hand the result + * to the backend. The innermost `next` applies the seam's conservative + * default rules, so an unconfigured deployment still never exports raw + * credential shapes; 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/redact', record, () => applyDefaultRedaction(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: Error): void { + 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': error.name, + turn, + step, + }, + body: { name: error.name, message: error.message }, + }) + } + + /** Lazily create the per-session first-chunk tracking set. */ + private seen(session: Session): Set { + 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 dispose, before the backend's `shutdown()`. */ +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' + case 'prompt/blocked': + return 'warn' + default: { + // Merge-extensible fall-through (no assertNever): types this seam does + // not depend on still get their RFC-pinned severity via a widened + // probe — `compact/end` is declared by dsh-compact, which the seam + // deliberately does not import. + const type: string = event.type + if (type === 'compact/end' && (event.data as { error?: unknown }).error !== undefined) return 'error' + return 'info' + } + } +} + +/** Build the minimal identity attributes: envelope plus self-contained header facts. */ +function identityOf(session: Session, event: SessionEvent): Record { + const attributes: Record = { + 'session.id': String(session.id), + 'event.type': event.type, + 'event.seq': event.seq, + } + const { cwd, parentSession } = session.header + if (cwd !== undefined) attributes['session.cwd'] = cwd + if (parentSession !== undefined) attributes['session.parent_id'] = String(parentSession) + return attributes +} diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts new file mode 100644 index 0000000000..93e7e7b0f9 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -0,0 +1,145 @@ +/** + * 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 { + /** + * Redact one outbound record before it reaches the backend. The innermost + * `next()` applies the seam's conservative default rule set + * (credential-shape scrubbing); listeners stack stricter rules by + * transforming its return value, and returning without `next()` replaces + * the default — the exported record is then only as clean as the + * replacing rule. 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/redact'(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, `compact/end` + * errors) and for `agent-error` operational records, `warn` for + * `prompt/blocked`, `info` for everything else. + */ +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 + /** + * 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). + */ + flush?(): void + /** + * Forward the fiber's disposal to the SDK: flush whatever is queued and + * reach quiescence, per the SDK's own shutdown contract. 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 +} + +/** + * 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 +} + +export { TelemetryCoordinator } from './coordinator.ts' +export { applyDefaultRedaction, REDACTION_PLACEHOLDER } from './redact.ts' diff --git a/packages/telemetry/session-telemetry/src/invariant.ts b/packages/telemetry/session-telemetry/src/invariant.ts new file mode 100644 index 0000000000..ffc55b0107 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry`. + * @module @deepseek-ai/dsh-session-telemetry/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' + +/** Cordis companion plugin name. */ +export const name = 'session-telemetry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the seam's whole output is the backend handoff — a + * synchronous `emit()` call outside every authoritative event stream — and its + * capture side never appends session events, so no event/data relation exists + * for an independent companion to observe. + */ +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 */ diff --git a/packages/telemetry/session-telemetry/src/redact.ts b/packages/telemetry/session-telemetry/src/redact.ts new file mode 100644 index 0000000000..d25452581a --- /dev/null +++ b/packages/telemetry/session-telemetry/src/redact.ts @@ -0,0 +1,77 @@ +/** + * Conservative default redaction for outbound telemetry records. + * + * Session-event bodies carry file contents and command output that may embed + * credentials; nothing may cross the seam to a backend unredacted. This module + * is the innermost rule set of the `telemetry/redact` waterfall — always + * applied unless an outer listener deliberately replaces the whole chain. It + * scrubs credential-SHAPED substrings from every string in the record body, + * leaving structure (keys, nesting, surrounding prose) intact. The pattern + * list is a security invariant, deliberately not configurable; deployments + * add stricter rules by stacking `telemetry/redact` listeners. + * + * @module @deepseek-ai/dsh-session-telemetry/redact + */ + +import type { TelemetryRecord } from './index.ts' + +/** Replacement text substituted for each detected credential-shaped span. */ +export const REDACTION_PLACEHOLDER = '[REDACTED]' + +/** + * Well-known credential shapes. A match anywhere inside a body string is + * replaced; low-signal values (package names, versions, git SHAs, plain URLs) + * deliberately stay untouched — they are the observability signal. + */ +const SECRET_PATTERNS: readonly RegExp[] = [ + /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/g, // DeepSeek / OpenAI / Anthropic API keys + /gh[pousr]_[A-Za-z0-9]{16,}/g, // GitHub personal/oauth/server/refresh tokens + /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT + /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens + /AKIA[0-9A-Z]{16}/g, // AWS access key id + /AIza[0-9A-Za-z_-]{35}/g, // Google API key + /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // PEM blocks + /\b(?[a-z][a-z0-9+.-]*):\/\/[^/\s:@]+:[^/\s:@]+@/g, // URL userinfo credentials +] + +/** Replace every known credential shape inside one string. */ +function scrub(text: string): string { + let out = text + for (const pattern of SECRET_PATTERNS) { + out = out.replace(pattern, REDACTION_PLACEHOLDER) + } + return out +} + +/** + * Deep-scrub every string inside a lossless-JSON value, preserving structure. + * The record body is the coordinator's own `structuredClone` — mutation-free + * rebuilding keeps the exported copy independent of the canonical log either way. + */ +function scrubValue(value: unknown): unknown { + if (typeof value === 'string') return scrub(value) + if (Array.isArray(value)) return value.map(scrubValue) + if (value !== null && typeof value === 'object') { + const out: Record = {} + for (const [key, entry] of Object.entries(value)) out[key] = scrubValue(entry) + return out + } + return value +} + +/** + * Apply the conservative default rule set to one record — the innermost + * `next` of the `telemetry/redact` waterfall. Attribute VALUES are scrubbed + * alongside the body (identity attributes are seam-built and boring, but + * `session.cwd` is caller-supplied); attribute keys are seam-owned constants. + * @param record - the candidate record; not mutated. + * @returns a redacted copy safe to hand to a backend. + */ +export function applyDefaultRedaction(record: TelemetryRecord): TelemetryRecord { + const attributes: Record = {} + for (const [key, value] of Object.entries(record.attributes)) { + attributes[key] = typeof value === 'string' ? scrub(value) : value + } + return { ...record, attributes, body: scrubValue(record.body) } +} diff --git a/packages/telemetry/session-telemetry/tests/redact.spec.ts b/packages/telemetry/session-telemetry/tests/redact.spec.ts new file mode 100644 index 0000000000..cd23af6da2 --- /dev/null +++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts @@ -0,0 +1,157 @@ +/** + * Default redaction rules and the `telemetry/redact` waterfall contract: + * credential shapes scrubbed from bodies and attribute values, structure + * preserved, canonical log untouched, listener stacking/replacement, and the + * fail-closed containment of a throwing rule. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { + applyDefaultRedaction, + REDACTION_PLACEHOLDER, + TelemetryCoordinator, + type TelemetryBackend, + type TelemetryRecord, +} from '../src/index.ts' + +const SECRETS = { + deepseek: 'sk-abcdef1234567890abcdef', + anthropic: 'sk-ant-abcdef1234567890', + githubPat: 'ghp_ABCDEFGHIJKLMNOPqrstuv12345678', + finePat: 'github_pat_ABCDEFGHIJKLMNOPQRSTuvwx', + slack: 'xoxb-1234567890-abcdefghij', + aws: 'AKIAIOSFODNN7EXAMPLE', + google: 'AIzaSyA-1234567890abcdefghijklmnopqrstu', + jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpM', + pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----', + urlCreds: 'https://user:hunter2@internal.example.com/repo.git', +} as const + +function record(body: unknown, attributes: Record = {}): TelemetryRecord { + return { channel: 'ledger', time: 1, severity: 'info', attributes, body } +} + +describe('applyDefaultRedaction', () => { + it('scrubs every known credential shape while preserving surrounding text', () => { + for (const secret of Object.values(SECRETS)) { + const out = applyDefaultRedaction(record(`before ${secret} after`)) + expect(out.body, secret).not.toContain(secret.includes('\n') ? 'MIIEow' : secret) + expect(out.body).toContain('before ') + expect(out.body).toContain(' after') + expect(out.body).toContain(REDACTION_PLACEHOLDER) + } + }) + + it('scrubs URL userinfo credentials but leaves plain URLs alone', () => { + const out = applyDefaultRedaction(record(`${SECRETS.urlCreds} and https://example.com/path`)) + expect(out.body).not.toContain('hunter2') + expect(out.body).toContain('https://example.com/path') + }) + + it('recurses through arrays and objects, preserving structure and non-strings', () => { + const out = applyDefaultRedaction(record({ + list: [`key=${SECRETS.deepseek}`, 7, null, true], + nested: { text: SECRETS.githubPat, count: 3 }, + })) + expect(out.body).toEqual({ + list: [`key=${REDACTION_PLACEHOLDER}`, 7, null, true], + nested: { text: REDACTION_PLACEHOLDER, count: 3 }, + }) + }) + + it('leaves low-signal values untouched', () => { + const clean = { + pkg: '@deepseek-ai/dsh-session-telemetry@0.0.1', + sha: '342a4c3a9d3adf13cf4ad33b9f8d6e79170be5e2', + prose: 'ordinary sentence with kebab-case-identifier', + } + expect(applyDefaultRedaction(record(clean)).body).toEqual(clean) + }) + + it('scrubs string attribute values and keeps numeric ones', () => { + const out = applyDefaultRedaction(record(null, { + 'session.cwd': `/home/${SECRETS.aws}/proj`, + 'event.seq': 4, + })) + expect(out.attributes['session.cwd']).toBe(`/home/${REDACTION_PLACEHOLDER}/proj`) + expect(out.attributes['event.seq']).toBe(4) + }) + + it('never mutates its input', () => { + const input = record({ text: SECRETS.slack }, { 'session.cwd': SECRETS.aws }) + applyDefaultRedaction(input) + expect((input.body as { text: string }).text).toBe(SECRETS.slack) + expect(input.attributes['session.cwd']).toBe(SECRETS.aws) + }) +}) + +class CollectingBackend implements TelemetryBackend { + records: TelemetryRecord[] = [] + emit(record: TelemetryRecord): void { + this.records.push(record) + } + async shutdown(): Promise {} +} + +async function setup() { + const backend = new CollectingBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + return { ctx, backend } +} + +describe('telemetry/redact waterfall', () => { + it('applies the default rules when no listener is registered', async () => { + const { ctx, backend } = await setup() + const session = ctx.sessions.create(SessionId('w')) + session.append('user/message', { content: [{ type: 'text', text: `key ${SECRETS.deepseek}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const body = backend.records[0]!.body as { content: { text: string }[] } + expect(body.content[0]!.text).toBe(`key ${REDACTION_PLACEHOLDER}`) + }) + + it('keeps the canonical log unredacted', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create(SessionId('log')) + session.append('user/message', { content: [{ type: 'text', text: SECRETS.githubPat }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const logged = session.events[0]!.data as { content: { text: string }[] } + expect(logged.content[0]!.text).toBe(SECRETS.githubPat) + }) + + it('lets a listener stack a stricter rule on top of the defaults', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', (_record, next) => { + const defaulted = next() + return { ...defaulted, body: { shapeOnly: true } } + }) + const session = ctx.sessions.create(SessionId('stack')) + session.append('user/message', { content: [{ type: 'text', text: 'anything' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toEqual({ shapeOnly: true }) + }) + + it('a listener that skips next() replaces the default rules', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', record => record) + const session = ctx.sessions.create(SessionId('veto')) + session.append('user/message', { content: [{ type: 'text', text: SECRETS.slack }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const body = backend.records[0]!.body as { content: { text: string }[] } + expect(body.content[0]!.text).toBe(SECRETS.slack) + }) + + it('a throwing rule withholds the record fail-closed without disturbing the log', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', () => { + throw new Error('rule exploded') + }) + const session = ctx.sessions.create(SessionId('closed')) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records).toHaveLength(0) + expect(session.events).toHaveLength(1) + }) +}) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts new file mode 100644 index 0000000000..9f694749ef --- /dev/null +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -0,0 +1,313 @@ +/** + * Coordinator semantics against a bare fake backend — the RFC's named unit + * tier for the seam: adoption (fresh, seeded, re-adoption via the handoff + * cursor), the fixed chunk projection, deep-copy isolation, turn-latency and + * dispose-ordering pins, failure containment, and the `agent/error` relay. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord } from '../src/index.ts' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * Test-only merged event proving unknown types flow through unchanged. + * @mode emit + * @param payload - opaque test payload + */ + 'telemetry-test/opaque': { payload: { nested: string[] } } + /** + * Test-only stand-in for dsh-compact's merge, exercising the widened severity probe. + * @mode emit + * @param error - failure text when the compaction failed + */ + 'compact/end': { turn: number; error?: string } + } +} + +class FakeBackend implements TelemetryBackend { + records: TelemetryRecord[] = [] + calls: string[] = [] + emitError: Error | undefined + shutdownError: Error | undefined + shutdownResolved = false + + emit(record: TelemetryRecord): void { + if (this.emitError) throw this.emitError + this.records.push(record) + this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`) + } + + flush = vi.fn() + + async shutdown(): Promise { + this.calls.push('shutdown') + await new Promise(resolve => setTimeout(resolve, 5)) + if (this.shutdownError) throw this.shutdownError + this.shutdownResolved = true + } + + ledger(): TelemetryRecord[] { + return this.records.filter(r => r.channel === 'ledger') + } +} + +async function setup(backend: FakeBackend = new FakeBackend()) { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + return { ctx, backend, fiber } +} + +function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session { + return ctx.sessions.create(SessionId(id), { meta: {} }) +} + +function appendTurn(session: Session): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) +} + +describe('TelemetryCoordinator capture', () => { + it('hands every appended event over with envelope identity and cloned body', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx, 'cap') + appendTurn(session) + + const start = backend.ledger()[0]! + const message = backend.ledger()[1]! + expect(start.attributes).toMatchObject({ 'session.id': 'cap', 'event.type': 'turn/start', 'event.seq': 0 }) + expect(start.time).toBe(session.events[0]!.time) + expect(start.severity).toBe('info') + expect(message.attributes['event.seq']).toBe(1) + // Deep-copy isolation: mutating the handed-off body never reaches the log. + ;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered' + const logged = session.events[1] as SessionEvent<'user/message'> + expect(logged.data.content[0]).toMatchObject({ text: 'hello' }) + }) + + it('stamps header facts on every record when present', async () => { + const { ctx, backend } = await setup() + const parent = SessionId('parent') + const session = ctx.sessions.create(SessionId('child'), { meta: { cwd: '/tmp/proj', parentSession: parent } }) + appendTurn(session) + for (const record of backend.ledger()) { + expect(record.attributes['session.cwd']).toBe('/tmp/proj') + expect(record.attributes['session.parent_id']).toBe('parent') + } + }) + + it('maps outcome flags to severity, including the widened merge-extensible probe', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('tool/result', { turn: 1, step: 1, callId: 'c1' as never, content: [], isError: true }, { surfaceOp: 'append' }) + session.append('tool/result', { turn: 1, step: 1, callId: 'c2' as never, content: [], isError: false }, { surfaceOp: 'append' }) + session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) + session.append('compact/end', { turn: 1, error: 'summarizer died' }) + session.append('compact/end', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity]) + expect(severities).toEqual([ + ['turn/start', 'info'], + ['tool/result', 'error'], + ['tool/result', 'info'], + ['prompt/blocked', 'warn'], + ['compact/end', 'error'], + ['compact/end', 'info'], + ['turn/end', 'error'], + ]) + }) + + it('passes unknown merged event types through unchanged', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx) + session.append('telemetry-test/opaque', { payload: { nested: ['a', 'b'] } }) + const record = backend.ledger()[0]! + expect(record.attributes['event.type']).toBe('telemetry-test/opaque') + expect(record.severity).toBe('info') + expect(record.body).toEqual({ payload: { nested: ['a', 'b'] } }) + }) + + it('ships only the first chunk of each (turn, step), per session', async () => { + const { ctx, backend } = await setup() + const a = liveSession(ctx, 'a') + const b = liveSession(ctx, 'b') + const chunk = (s: Session, turn: number, step: number, text: string) => + s.append('assistant/chunk', { turn, step, chunk: { type: 'text-delta', index: 0, text } }) + chunk(a, 1, 1, 'a11-first') + chunk(a, 1, 1, 'a11-second') + chunk(a, 1, 2, 'a12-first') + chunk(b, 1, 1, 'b11-first') + chunk(b, 1, 1, 'b11-second') + const shipped = backend.ledger().map(r => [r.attributes['session.id'], (r.body as { chunk: { text: string } }).chunk.text]) + expect(shipped).toEqual([ + ['a', 'a11-first'], + ['a', 'a12-first'], + ['b', 'b11-first'], + ]) + }) +}) + +describe('TelemetryCoordinator adoption', () => { + it('reads seeded events back at adoption (fork/resume seeds never re-emit)', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const parent = liveSession(ctx, 'seed-parent') + appendTurn(parent) + ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]) + expect(seqs).toEqual(expect.arrayContaining([ + ['seed-parent', 0], ['seed-parent', 1], + ['seeded', 0], ['seeded', 1], + ])) + }) + + it('adopts exactly once when created fires after the sweep', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + // The enter/announce window: prepare+enter puts the session in the store + // (visible to the constructor sweep) before `session/created` fires, so a + // coordinator loaded inside that window sees the session twice — sweep + // first, created second. The second adoption must be a no-op. + const session = ctx.sessions.prepare(SessionId('overlap')) + appendTurn(session) + ctx.sessions.enter(session) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(backend.ledger()).toHaveLength(2) + ctx.sessions.announce(session) + expect(backend.ledger()).toHaveLength(2) + }) + + it('resumes from the handoff cursor across a reload, re-dropping mid-step chunks', async () => { + const backend = new FakeBackend() + const { ctx, fiber } = await setup(backend) + const session = liveSession(ctx, 'hmr') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) + expect(backend.ledger()).toHaveLength(2) + + await fiber.dispose() + // The reload window: appends while no telemetry listener is registered. + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'mid-step continuation' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const second = new FakeBackend() + await ctx.plugin({ + name: 'fake-telemetry-2', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, second), + }) + // Only the window events past the cursor are re-handed, and the mid-step + // continuation is re-dropped because ≤cursor events rebuilt the projection. + expect(second.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end']) + }) + + it('re-hands the full log when no cursor survived (fresh session object)', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = liveSession(ctx, 'fresh') + appendTurn(session) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 1]) + }) +}) + +describe('TelemetryCoordinator lifecycle and containment', () => { + it('forwards session/flush as a hint without awaiting backend work', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx) + let settled = false + backend.flush.mockImplementation(() => { + // The backend may kick off arbitrary async work; the loop's parallel must not wait for it. + void new Promise(resolve => setTimeout(resolve, 50)).then(() => { settled = true }) + }) + await ctx.parallel('session/flush', session) + expect(backend.flush).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + }) + + it('ignores flush hints for sessions it never adopted', async () => { + const { ctx, backend } = await setup() + const stranger = ctx.sessions.prepare(SessionId('stranger'), { meta: {} }) + await ctx.parallel('session/flush', stranger) + expect(backend.flush).not.toHaveBeenCalled() + }) + + it('emits each adopted session’s shutdown record before awaiting backend shutdown', async () => { + const { ctx, backend, fiber } = await setup() + liveSession(ctx, 's1') + liveSession(ctx, 's2') + await fiber.dispose() + expect(backend.calls).toEqual(['emit:shutdown', 'emit:shutdown', 'shutdown']) + expect(backend.shutdownResolved).toBe(true) + const ops = backend.records.filter(r => r.channel === 'ops') + expect(ops.map(r => r.attributes['session.id']).sort()).toEqual(['s1', 's2']) + expect(ops.every(r => r.attributes['telemetry.op'] === 'shutdown' && r.severity === 'info')).toBe(true) + expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true) + }) + + it('warns instead of throwing when backend shutdown fails', async () => { + const backend = new FakeBackend() + backend.shutdownError = new Error('exporter unreachable') + const { ctx, fiber } = await setup(backend) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + liveSession(ctx) + await expect(fiber.dispose()).resolves.not.toThrow() + expect(warn.mock.calls.some(args => String(args[0]).includes('shutdown failed'))).toBe(true) + }) + + it('contains emit failures: the append succeeds and capture heals', async () => { + const { ctx, backend } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const session = liveSession(ctx) + backend.emitError = new Error('backend broke') + expect(() => session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow() + expect(warn).toHaveBeenCalled() + backend.emitError = undefined + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(backend.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end']) + }) + + it('relays agent/error as an ops record with identity and structured name', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx, 'erring') + // Only the members the relay reads; the full Agent surface is irrelevant here. + const agent = { id: 'agent-1', session } as Agent + ctx.emit('agent/error', agent, 3, 2, new TypeError('adapter exploded')) + const record = backend.records.find(r => r.channel === 'ops')! + expect(record.severity).toBe('error') + expect(record.attributes).toMatchObject({ + 'telemetry.op': 'agent-error', + 'session.id': 'erring', + 'agent.id': 'agent-1', + 'error.name': 'TypeError', + turn: 3, + step: 2, + }) + expect(record.body).toEqual({ name: 'TypeError', message: 'adapter exploded' }) + }) +}) diff --git a/packages/telemetry/session-telemetry/tsconfig.json b/packages/telemetry/session-telemetry/tsconfig.json new file mode 100644 index 0000000000..2c18a582e4 --- /dev/null +++ b/packages/telemetry/session-telemetry/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd75466c5..0e96b9cf4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,9 @@ importers: '@deepseek-ai/dsh-session-query-sqlite': specifier: workspace:* version: link:../packages/session-query/session-query-sqlite + '@deepseek-ai/dsh-session-telemetry-otel': + specifier: workspace:* + version: link:../packages/telemetry/session-telemetry-otel '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm @@ -3538,6 +3541,61 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/telemetry/session-telemetry: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/telemetry/session-telemetry-otel: + dependencies: + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/api-logs': + specifier: ^0.220.0 + version: 0.220.0 + '@opentelemetry/exporter-logs-otlp-http': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^2.9.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-telemetry': + specifier: workspace:^ + version: link:../session-telemetry + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/timeout/timeout-policy: devDependencies: '@deepseek-ai/dsh-invariants': @@ -5803,10 +5861,78 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@opentelemetry/api-logs@0.220.0': + resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.9.0': + resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.220.0': + resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.220.0': + resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.220.0': + resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.9.0': + resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.220.0': + resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.9.0': + resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.9.0': + resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.43.0': resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} @@ -10711,8 +10837,82 @@ snapshots: '@nodable/entities@2.2.0': {} + '@opentelemetry/api-logs@0.220.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/semantic-conventions@1.43.0': {} '@oxc-parser/binding-android-arm-eabi@0.133.0': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a3274a83c3..4912729ff4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -221,6 +221,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts', SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts', SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts', + TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts', WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1f54f7358f..075f394565 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -77,6 +77,7 @@ const GROUP_ORDER = [ 'session-persistence', 'session-query', 'session-title', + 'telemetry', 'support', 'ui', ] @@ -132,6 +133,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query', 'session-query-sqlite'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, + { + key: 'telemetry', + pkg: 'session-telemetry', + title: 'Session telemetry seam', + mode: 'seam', + implementations: ['session-telemetry-otel'], + consumers: [], + note: 'The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process.', + }, { key: 'sessionQuery', pkg: 'session-query', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d5fa282a24..42792f14ca 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -80,6 +80,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, + 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, + 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 56ab3ffef3..e33ef26038 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -74,6 +74,7 @@ "./packages/hooks/*/src/invariant.ts", "./packages/session-persistence/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/telemetry/*/src/invariant.ts", "./packages/sdk/*/src/invariant.ts", "./packages/ui/*/src/invariant.ts", "./packages/examples/*/src/invariant.ts", @@ -142,6 +143,7 @@ "./packages/session-persistence/*/src", "./packages/session-query/*/src", "./packages/session-title/*/src", + "./packages/telemetry/*/src", "./packages/sdk/*/src", "./packages/ui/*/src", "./packages/examples/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index a13bcf35e3..465b38edf4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -48,6 +48,8 @@ { "path": "./packages/session-title/session-title-llm" }, { "path": "./packages/session-title/session-title-first-message-llm" }, { "path": "./packages/session-title/session-title-all-messages-llm" }, + { "path": "./packages/telemetry/session-telemetry" }, + { "path": "./packages/telemetry/session-telemetry-otel" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/commands" }, From 70febffe1ad87f539cc50e0f5ea1d1cde5b0dc91 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 11:58:47 +0800 Subject: [PATCH 02/22] refactor(telemetry): ship the redact waterfall without built-in rules The seam keeps the telemetry/redact scrubbing interface but ships no rules of its own: the innermost next() passes records through unchanged, and deployments mount their rules as waterfall listeners. As an SDK we cannot know which patterns are secrets in a given deployment; a shipped list invites false confidence while catching only known shapes, and false positives would corrupt exported bodies. Mechanism stays with the seam, policy moves to the deployment; both READMEs and the Agent Note state the raw-export default plainly. The loader-composition e2e now mounts a deployment-style rule fixture and pins the same wire behavior: secret absent, placeholder present, canonical log untouched. --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 10 +- ...07-23-session-telemetry-otel-revival.zh.md | 10 +- docs/cordis-catalog/events.md | 25 +-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../tests/fixtures/telemetry-otel.cordis.yml | 5 + .../tests/fixtures/telemetry-redact-rule.ts | 29 ++++ knip.json | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/telemetry/README.md | 2 +- .../session-telemetry-otel/README.md | 2 +- .../tests/loader-composition.e2e.ts | 15 +- .../telemetry/session-telemetry/README.md | 4 +- .../session-telemetry/src/coordinator.ts | 18 +- .../telemetry/session-telemetry/src/index.ts | 22 +-- .../telemetry/session-telemetry/src/redact.ts | 77 --------- .../session-telemetry/tests/redact.spec.ts | 159 +++++++----------- 18 files changed, 155 insertions(+), 236 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts delete mode 100644 packages/telemetry/session-telemetry/src/redact.ts diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 31a5914d0d..e5c566662a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-session-telemetry-otel-revival.md: 1150d363e9db98a39e1188b86dc41fa291edba41 -2026-07-23-session-telemetry-otel-revival.zh.md: 76749a59c38ef2b8c5b3f09960ca500c25d8e1a8 +2026-07-23-session-telemetry-otel-revival.md: 28a88218566dcfe34a3b99b682a4c21d22e00ce2 +2026-07-23-session-telemetry-otel-revival.zh.md: 79428c5fbb76bdd8f3c2d0b856edd712b6a650d0 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 1150d363e9..28a8821856 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -10,10 +10,10 @@ Every deployment that wants harness sessions in an observability stack must hand ## Decision -`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go, and nothing crosses the seam unredacted: +`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/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the innermost `next()` applies a conservative built-in rule set (credential shapes: API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo), deployments stack stricter rules as listeners, and a throwing rule withholds the record fail-closed. The pattern list is a security invariant, deliberately not configurable. Redaction applies to the exported copy only; the canonical log is never rewritten. +- **The `telemetry/redact` waterfall** — the delta over the branch version. 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. @@ -22,12 +22,12 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **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. -**Export without built-in redaction, delegating to receiver-side collector processors.** Rejected — this is what legal declined. Receiver-side redaction ships the secret first and scrubs it second; the seam must scrub before bytes leave the process, and a waterfall makes the redaction point auditable and stackable. +**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 configurable pattern list for the default rules.** Rejected: deployment-varying tunables belong in config, but a security invariant does not — weakening the floor should require code, not YAML. Stricter rules stack as `telemetry/redact` listeners. +**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. ## 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. Credential-shaped substrings never leave the process even on a rule-free deployment, at the cost of a synchronous per-record scrub on the capture path (string-regex over lossless-JSON bodies — bounded by event size, no I/O). Exported bodies can differ from canonical log bytes wherever the placeholder landed, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; 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/redact` 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. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 76749a59c3..79428c5fbb 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -10,10 +10,10 @@ Status: implemented ## Decision -`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向,且任何数据未经脱敏不得跨越 seam: +`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/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;最内层 `next()` 应用保守的内置规则集(凭据形状:API key、GitHub/Slack token、AWS/Google key、JWT、PEM 块、URL userinfo),部署方以监听器堆叠更严规则,抛异常的规则将该记录 fail-closed 扣下。模式列表是安全不变量,刻意不可配置。脱敏只作用于导出副本;canonical log 永不改写。 +- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 - **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 @@ -22,12 +22,12 @@ Status: implemented **实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 -**不带内置脱敏直接导出,交给接收端 collector processor。** 否决——这正是法务否掉的方案。接收端脱敏是先把秘密发出去再擦除;seam 必须在字节离开进程前擦除,且 waterfall 使脱敏点可审计、可堆叠。 +**不设进程内脱敏点,交给接收端 collector processor。** 否决——接收端脱敏是先把秘密发出去再擦除。waterfall 在字节离开进程前提供一个可审计、可堆叠的擦除点;分支版本(PR #222 交付的形态)完全没有脱敏点,如今每条记录都必经其一。 -**默认规则的模式列表做成可配置。** 否决:随部署变化的调优项应进 config,但安全不变量不应——削弱底线应当需要改代码而非改 YAML。更严格的规则以 `telemetry/redact` 监听器堆叠。 +**在 waterfall 最内层 `next()` 内置一套保守规则集。** 否决:作为 SDK 我们无法预知某个部署里什么模式算秘密,内置列表只覆盖已知形状却会带来"脱敏已开启"的虚假信心,且误报会替从未要求过的消费者破坏导出 body。seam 拥有机制,部署方拥有策略——最内层 `next()` 原样透传,规则以监听器挂载。 **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 ## Consequences -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。即使部署方未配置任何规则,凭据形状的子串也绝不离开进程,代价是捕获路径上每条记录一次同步擦除(对 lossless-JSON body 做字符串正则——受事件大小约束,无 I/O)。导出的 body 在占位符落点处可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/redact` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ed5de022b..5356f07a5b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -807,20 +807,21 @@ Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/syst ### `telemetry/redact` — waterfall -Redact one outbound record before it reaches the backend. The innermost `next()` applies the seam's conservative default rule set (credential-shape scrubbing); listeners stack stricter rules by transforming its return value, and returning without `next()` replaces the default — the exported record is then only as clean as the replacing rule. 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. +Redact one outbound record before it reaches the backend — the seam's scrubbing extension point. The seam 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 /** - * Redact one outbound record before it reaches the backend. The innermost - * `next()` applies the seam's conservative default rule set - * (credential-shape scrubbing); listeners stack stricter rules by - * transforming its return value, and returning without `next()` replaces - * the default — the exported record is then only as clean as the - * replacing rule. 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. + * Redact one outbound record before it reaches the backend — the seam's + * scrubbing extension point. The seam 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 @@ -828,7 +829,7 @@ Redact one outbound record before it reaches the backend. The innermost `next()` 'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:39`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:40`](../../packages/telemetry/session-telemetry/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22120b2f8e..3dec10f786 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1554,7 +1554,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:123`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:124`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 30380261fe..da367e81f2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts: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/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:39`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | +| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:40`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `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) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index defde98ff5..ff81d0116c 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -1,8 +1,13 @@ # 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/redact 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' diff --git a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts new file mode 100644 index 0000000000..10b7081dd3 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts @@ -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/redact` 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/redact', (_record, next) => { + const record = next() + return { ...record, body: scrub(record.body) } + }) +} diff --git a/knip.json b/knip.json index 728ded39b7..753b3dda34 100644 --- a/knip.json +++ b/knip.json @@ -32,6 +32,7 @@ "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", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index aee1ac9ae8..f8377ffd54 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1124,8 +1124,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'telemetry/redact', mode: 'waterfall', signature: '\'telemetry/redact\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', - jsDoc: '/**\n * Redact one outbound record before it reaches the backend. The innermost\n * `next()` applies the seam\'s conservative default rule set\n * (credential-shape scrubbing); listeners stack stricter rules by\n * transforming its return value, and returning without `next()` replaces\n * the default — the exported record is then only as clean as the\n * replacing rule. Dispatched synchronously on the capture hot path inside\n * the coordinator\'s containment: a throwing listener withholds that one\n * record (fail-closed) and never reaches the agent loop. Redaction\n * applies to the exported copy only; the canonical session log is never\n * 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: 'Redact one outbound record before it reaches the backend.', + jsDoc: '/**\n * Redact one outbound record before it reaches the backend — the seam\'s\n * scrubbing extension point. The seam ships NO rules 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: 'Redact one outbound record before it reaches the backend — the seam\'s scrubbing extension point.', }, { name: 'tools/change', diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index a5869077c2..ecb348d256 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -1,6 +1,6 @@ # telemetry/ -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 mandatory `telemetry/redact` waterfall, 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). +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/redact` waterfall (deployment-mounted 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 | |---|---| diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 26edc62645..3fe9f34626 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -19,7 +19,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th ## What leaves the machine -Records carry the seam's REDACTED copy of `event.data` — 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) — after the seam's `telemetry/redact` waterfall has scrubbed credential-shaped substrings (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. A deployment with stricter requirements stacks `telemetry/redact` listeners or opts out structurally. +Records carry the complete `event.data` as the seam's `telemetry/redact` 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/redact` 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 diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts index 2c72fd6170..8f16662614 100644 --- a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts +++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -3,8 +3,8 @@ * 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, default redaction, ops markers, and the untouched - * canonical log. + * 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' @@ -12,7 +12,6 @@ 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' -import { REDACTION_PLACEHOLDER } from '@deepseek-ai/dsh-session-telemetry' const driver = fileURLToPath(new URL( '../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts', @@ -25,6 +24,7 @@ const configPath = fileURLToPath(new 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 }[] @@ -84,15 +84,16 @@ describe('session-telemetry-otel through a real headless cordis.yml', () => { } expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true) - // Default redaction on the wire: the fixture credential never leaves the - // process, its surrounding prose does, and the placeholder marks the spot. + // 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(REDACTION_PLACEHOLDER) + 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(REDACTION_PLACEHOLDER) + expect(logContent).not.toContain(FIXTURE_PLACEHOLDER) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index bccc61fd06..0f3fe0ec28 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -12,7 +12,7 @@ The coordinator registers, all through the composing fiber's effects: `session/c ## The redact waterfall -Every record passes the `telemetry/redact` waterfall between projection and `emit()` — nothing reaches a backend unredacted. The innermost `next()` applies the built-in conservative rule set (`applyDefaultRedaction`: credential shapes — API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo — replaced with `[REDACTED]` in body strings and string attribute values). Listeners stack stricter rules by transforming `next()`'s return value; returning without `next()` replaces the default rule set, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. The built-in pattern list is a security invariant, deliberately not configurable from cordis.yml. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Every record passes the `telemetry/redact` 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 @@ -37,4 +37,4 @@ 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). -- **Redaction is shape-based** — the default rules catch known credential shapes, not every secret; a deployment with stricter needs stacks `telemetry/redact` listeners, and exported data is only as clean as the mounted rules. +- **No built-in redaction rules** — with no `telemetry/redact` 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. diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 08fbe498b1..8e464c6add 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -2,10 +2,11 @@ * 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/redact` waterfall, and hands the redacted copy 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. + * `telemetry/redact` waterfall (deployment-mounted 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 */ @@ -14,7 +15,6 @@ 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' -import { applyDefaultRedaction } from './redact.ts' /** * The handoff cursor: per session, the highest `seq` handed to a backend. @@ -145,13 +145,13 @@ export class TelemetryCoordinator { /** * Run the `telemetry/redact` waterfall over one record and hand the result - * to the backend. The innermost `next` applies the seam's conservative - * default rules, so an unconfigured deployment still never exports raw - * credential shapes; callers run inside {@link contain}, so a throwing + * 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/redact', record, () => applyDefaultRedaction(record))) + this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => record)) } /** Forward the turn-end boundary to the backend's optional flush hint. */ diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 93e7e7b0f9..65f5868e04 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -22,16 +22,17 @@ declare module 'cordis' { interface Events { /** - * Redact one outbound record before it reaches the backend. The innermost - * `next()` applies the seam's conservative default rule set - * (credential-shape scrubbing); listeners stack stricter rules by - * transforming its return value, and returning without `next()` replaces - * the default — the exported record is then only as clean as the - * replacing rule. 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. + * Redact one outbound record before it reaches the backend — the seam's + * scrubbing extension point. The seam 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 @@ -142,4 +143,3 @@ export abstract class Telemetry extends Service implements TelemetryBackend { } export { TelemetryCoordinator } from './coordinator.ts' -export { applyDefaultRedaction, REDACTION_PLACEHOLDER } from './redact.ts' diff --git a/packages/telemetry/session-telemetry/src/redact.ts b/packages/telemetry/session-telemetry/src/redact.ts deleted file mode 100644 index d25452581a..0000000000 --- a/packages/telemetry/session-telemetry/src/redact.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Conservative default redaction for outbound telemetry records. - * - * Session-event bodies carry file contents and command output that may embed - * credentials; nothing may cross the seam to a backend unredacted. This module - * is the innermost rule set of the `telemetry/redact` waterfall — always - * applied unless an outer listener deliberately replaces the whole chain. It - * scrubs credential-SHAPED substrings from every string in the record body, - * leaving structure (keys, nesting, surrounding prose) intact. The pattern - * list is a security invariant, deliberately not configurable; deployments - * add stricter rules by stacking `telemetry/redact` listeners. - * - * @module @deepseek-ai/dsh-session-telemetry/redact - */ - -import type { TelemetryRecord } from './index.ts' - -/** Replacement text substituted for each detected credential-shaped span. */ -export const REDACTION_PLACEHOLDER = '[REDACTED]' - -/** - * Well-known credential shapes. A match anywhere inside a body string is - * replaced; low-signal values (package names, versions, git SHAs, plain URLs) - * deliberately stay untouched — they are the observability signal. - */ -const SECRET_PATTERNS: readonly RegExp[] = [ - /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/g, // DeepSeek / OpenAI / Anthropic API keys - /gh[pousr]_[A-Za-z0-9]{16,}/g, // GitHub personal/oauth/server/refresh tokens - /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT - /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens - /AKIA[0-9A-Z]{16}/g, // AWS access key id - /AIza[0-9A-Za-z_-]{35}/g, // Google API key - /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // PEM blocks - /\b(?[a-z][a-z0-9+.-]*):\/\/[^/\s:@]+:[^/\s:@]+@/g, // URL userinfo credentials -] - -/** Replace every known credential shape inside one string. */ -function scrub(text: string): string { - let out = text - for (const pattern of SECRET_PATTERNS) { - out = out.replace(pattern, REDACTION_PLACEHOLDER) - } - return out -} - -/** - * Deep-scrub every string inside a lossless-JSON value, preserving structure. - * The record body is the coordinator's own `structuredClone` — mutation-free - * rebuilding keeps the exported copy independent of the canonical log either way. - */ -function scrubValue(value: unknown): unknown { - if (typeof value === 'string') return scrub(value) - if (Array.isArray(value)) return value.map(scrubValue) - if (value !== null && typeof value === 'object') { - const out: Record = {} - for (const [key, entry] of Object.entries(value)) out[key] = scrubValue(entry) - return out - } - return value -} - -/** - * Apply the conservative default rule set to one record — the innermost - * `next` of the `telemetry/redact` waterfall. Attribute VALUES are scrubbed - * alongside the body (identity attributes are seam-built and boring, but - * `session.cwd` is caller-supplied); attribute keys are seam-owned constants. - * @param record - the candidate record; not mutated. - * @returns a redacted copy safe to hand to a backend. - */ -export function applyDefaultRedaction(record: TelemetryRecord): TelemetryRecord { - const attributes: Record = {} - for (const [key, value] of Object.entries(record.attributes)) { - attributes[key] = typeof value === 'string' ? scrub(value) : value - } - return { ...record, attributes, body: scrubValue(record.body) } -} diff --git a/packages/telemetry/session-telemetry/tests/redact.spec.ts b/packages/telemetry/session-telemetry/tests/redact.spec.ts index cd23af6da2..c4b9d4d5bb 100644 --- a/packages/telemetry/session-telemetry/tests/redact.spec.ts +++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts @@ -1,91 +1,19 @@ /** - * Default redaction rules and the `telemetry/redact` waterfall contract: - * credential shapes scrubbed from bodies and attribute values, structure - * preserved, canonical log untouched, listener stacking/replacement, and the - * fail-closed containment of a throwing rule. + * The `telemetry/redact` waterfall contract: pass-through when no listener is + * mounted, listener stacking and replacement, ops-record coverage, the + * untouched canonical log, and the fail-closed containment of a throwing rule. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { - applyDefaultRedaction, - REDACTION_PLACEHOLDER, TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord, } from '../src/index.ts' -const SECRETS = { - deepseek: 'sk-abcdef1234567890abcdef', - anthropic: 'sk-ant-abcdef1234567890', - githubPat: 'ghp_ABCDEFGHIJKLMNOPqrstuv12345678', - finePat: 'github_pat_ABCDEFGHIJKLMNOPQRSTuvwx', - slack: 'xoxb-1234567890-abcdefghij', - aws: 'AKIAIOSFODNN7EXAMPLE', - google: 'AIzaSyA-1234567890abcdefghijklmnopqrstu', - jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpM', - pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----', - urlCreds: 'https://user:hunter2@internal.example.com/repo.git', -} as const - -function record(body: unknown, attributes: Record = {}): TelemetryRecord { - return { channel: 'ledger', time: 1, severity: 'info', attributes, body } -} - -describe('applyDefaultRedaction', () => { - it('scrubs every known credential shape while preserving surrounding text', () => { - for (const secret of Object.values(SECRETS)) { - const out = applyDefaultRedaction(record(`before ${secret} after`)) - expect(out.body, secret).not.toContain(secret.includes('\n') ? 'MIIEow' : secret) - expect(out.body).toContain('before ') - expect(out.body).toContain(' after') - expect(out.body).toContain(REDACTION_PLACEHOLDER) - } - }) - - it('scrubs URL userinfo credentials but leaves plain URLs alone', () => { - const out = applyDefaultRedaction(record(`${SECRETS.urlCreds} and https://example.com/path`)) - expect(out.body).not.toContain('hunter2') - expect(out.body).toContain('https://example.com/path') - }) - - it('recurses through arrays and objects, preserving structure and non-strings', () => { - const out = applyDefaultRedaction(record({ - list: [`key=${SECRETS.deepseek}`, 7, null, true], - nested: { text: SECRETS.githubPat, count: 3 }, - })) - expect(out.body).toEqual({ - list: [`key=${REDACTION_PLACEHOLDER}`, 7, null, true], - nested: { text: REDACTION_PLACEHOLDER, count: 3 }, - }) - }) - - it('leaves low-signal values untouched', () => { - const clean = { - pkg: '@deepseek-ai/dsh-session-telemetry@0.0.1', - sha: '342a4c3a9d3adf13cf4ad33b9f8d6e79170be5e2', - prose: 'ordinary sentence with kebab-case-identifier', - } - expect(applyDefaultRedaction(record(clean)).body).toEqual(clean) - }) - - it('scrubs string attribute values and keeps numeric ones', () => { - const out = applyDefaultRedaction(record(null, { - 'session.cwd': `/home/${SECRETS.aws}/proj`, - 'event.seq': 4, - })) - expect(out.attributes['session.cwd']).toBe(`/home/${REDACTION_PLACEHOLDER}/proj`) - expect(out.attributes['event.seq']).toBe(4) - }) - - it('never mutates its input', () => { - const input = record({ text: SECRETS.slack }, { 'session.cwd': SECRETS.aws }) - applyDefaultRedaction(input) - expect((input.body as { text: string }).text).toBe(SECRETS.slack) - expect(input.attributes['session.cwd']).toBe(SECRETS.aws) - }) -}) +const FIXTURE_SECRET = 'sk-fixture1234567890' class CollectingBackend implements TelemetryBackend { records: TelemetryRecord[] = [] @@ -99,49 +27,80 @@ async function setup() { const backend = new CollectingBackend() const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin({ + const fiber = await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), }) - return { ctx, backend } + return { ctx, backend, fiber } } describe('telemetry/redact waterfall', () => { - it('applies the default rules when no listener is registered', async () => { + it('passes records through unchanged when no listener is mounted', async () => { const { ctx, backend } = await setup() const session = ctx.sessions.create(SessionId('w')) - session.append('user/message', { content: [{ type: 'text', text: `key ${SECRETS.deepseek}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: `key ${FIXTURE_SECRET}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const body = backend.records[0]!.body as { content: { text: string }[] } - expect(body.content[0]!.text).toBe(`key ${REDACTION_PLACEHOLDER}`) + expect(body.content[0]!.text).toBe(`key ${FIXTURE_SECRET}`) }) - it('keeps the canonical log unredacted', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create(SessionId('log')) - session.append('user/message', { content: [{ type: 'text', text: SECRETS.githubPat }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const logged = session.events[0]!.data as { content: { text: string }[] } - expect(logged.content[0]!.text).toBe(SECRETS.githubPat) - }) - - it('lets a listener stack a stricter rule on top of the defaults', async () => { - const { ctx, backend } = await setup() + it('applies a mounted rule to every outbound record, ops records included', async () => { + const { ctx, backend, fiber } = await setup() ctx.on('telemetry/redact', (_record, next) => { - const defaulted = next() - return { ...defaulted, body: { shapeOnly: true } } + const record = next() + return { ...record, body: { scrubbed: true } } + }) + const session = ctx.sessions.create(SessionId('rule')) + session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toEqual({ scrubbed: true }) + // The dispose-time shutdown ops record passes through the same waterfall. + await fiber.dispose() + const ops = backend.records.filter(record => record.channel === 'ops') + expect(ops).toHaveLength(1) + expect(ops[0]!.body).toEqual({ scrubbed: true }) + }) + + it('keeps the canonical log untouched by a mounted rule', async () => { + const { ctx } = await setup() + ctx.on('telemetry/redact', (_record, next) => ({ ...next(), body: null })) + const session = ctx.sessions.create(SessionId('log')) + session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const logged = session.events[0]!.data as { content: { text: string }[] } + expect(logged.content[0]!.text).toBe(FIXTURE_SECRET) + }) + + it('stacks listeners outermost-first around next()', async () => { + const { ctx, backend } = await setup() + const order: string[] = [] + ctx.on('telemetry/redact', (_record, next) => { + order.push('outer-before') + const record = next() + order.push('outer-after') + return { ...record, attributes: { ...record.attributes, outer: 1 } } + }) + ctx.on('telemetry/redact', (_record, next) => { + order.push('inner') + const record = next() + return { ...record, attributes: { ...record.attributes, inner: 1 } } }) const session = ctx.sessions.create(SessionId('stack')) - session.append('user/message', { content: [{ type: 'text', text: 'anything' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(backend.records[0]!.body).toEqual({ shapeOnly: true }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(order).toEqual(['outer-before', 'inner', 'outer-after']) + expect(backend.records[0]!.attributes).toMatchObject({ outer: 1, inner: 1 }) }) - it('a listener that skips next() replaces the default rules', async () => { + it('a listener that skips next() replaces everything beneath it', async () => { const { ctx, backend } = await setup() - ctx.on('telemetry/redact', record => record) + const inner = { called: false } + ctx.on('telemetry/redact', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord)) + ctx.on('telemetry/redact', (_record, next) => { + inner.called = true + return next() + }) const session = ctx.sessions.create(SessionId('veto')) - session.append('user/message', { content: [{ type: 'text', text: SECRETS.slack }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const body = backend.records[0]!.body as { content: { text: string }[] } - expect(body.content[0]!.text).toBe(SECRETS.slack) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toBe('replaced') + expect(inner.called).toBe(false) }) it('a throwing rule withholds the record fail-closed without disturbing the log', async () => { From b5523b0b483aedc6b0287917c10b3f8254bdf5a4 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 17:49:17 +0800 Subject: [PATCH 03/22] refactor(telemetry): drop dead live-collector smoke and the compact/end severity probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/otel.e2e.ts self-skipped on $DSH_OTLP_E2E_ENDPOINT, which nothing in the repo sets — it never ran; the mock-collector wire spec and the keyless Loader-composition e2e already cover the pipeline both ways. The severityOf compact/end probe parsed another package's merged event shape by string comparison — an untyped cross-package contract that breaks silently — and its only consumer was the test's own stand-in declaration. Unknown event types now uniformly fall through as info; outcome semantics stay with the owning package. --- docs/cordis-catalog/services.md | 2 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/tests/otel.e2e.ts | 25 ------------------- .../telemetry/session-telemetry/README.md | 2 +- .../session-telemetry/src/coordinator.ts | 12 +++------ .../telemetry/session-telemetry/src/index.ts | 7 +++--- .../session-telemetry/tests/telemetry.spec.ts | 14 +++-------- 7 files changed, 14 insertions(+), 50 deletions(-) delete mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3dec10f786..b9943aaf06 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1554,7 +1554,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:124`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:125`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 3fe9f34626..da0e96d61f 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -36,4 +36,4 @@ 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. -- **Live-collector smoke is opt-in** — the e2e smoke (`tests/otel.e2e.ts`) self-skips without `$DSH_OTLP_E2E_ENDPOINT`; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape against a mock collector on every run. +- **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. diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts deleted file mode 100644 index 91c92b3486..0000000000 --- a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Keyless-self-skipping smoke: ship one real session's records to a live - * OTLP collector named by $DSH_OTLP_E2E_ENDPOINT and require the SDK's - * shutdown (flush-and-quiesce) to resolve. Skipped without the endpoint so - * secretless CI stays green — a CI accommodation, not a cost signal. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import TelemetryOtel from '../src/index.ts' - -describe.skipIf(!process.env.DSH_OTLP_E2E_ENDPOINT)('telemetry-otel e2e (live collector)', () => { - it('exports a session and quiesces cleanly', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(TelemetryOtel, { - exporter: { url: process.env.DSH_OTLP_E2E_ENDPOINT! }, - }) - const session = ctx.sessions.create(SessionId(`e2e-${Date.now()}`), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await expect(fiber.dispose()).resolves.not.toThrow() - }) -}) diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 0f3fe0ec28..26a28baa2d 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -24,7 +24,7 @@ Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are drop ## The logical record -`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError`, `turn/end` error reasons, `compact/end` errors; WARN for `prompt/blocked`; INFO otherwise), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` 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. 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)`. +`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError` and `turn/end` error reasons; WARN for `prompt/blocked`; INFO otherwise, including plugin-merged event types whose outcome semantics stay with their owners), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` 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. 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 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 8e464c6add..ba1610247d 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -218,15 +218,11 @@ function severityOf(event: SessionEvent): TelemetrySeverity { return event.data.reason.kind === 'error' ? 'error' : 'info' case 'prompt/blocked': return 'warn' - default: { - // Merge-extensible fall-through (no assertNever): types this seam does - // not depend on still get their RFC-pinned severity via a widened - // probe — `compact/end` is declared by dsh-compact, which the seam - // deliberately does not import. - const type: string = event.type - if (type === 'compact/end' && (event.data as { error?: unknown }).error !== undefined) return 'error' + 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' - } } } diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 65f5868e04..09800aa1b5 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -44,9 +44,10 @@ declare module 'cordis' { /** * 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, `compact/end` - * errors) and for `agent-error` operational records, `warn` for - * `prompt/blocked`, `info` for everything else. + * says so (`tool/result.isError`, `turn/end` error reasons) and for + * `agent-error` operational records, `warn` for `prompt/blocked`, `info` + * for everything else — including event types merged by other packages, + * whose outcome semantics stay with their owners. */ export type TelemetrySeverity = 'info' | 'warn' | 'error' diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 9f694749ef..743e90073d 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -19,12 +19,6 @@ declare module '@deepseek-ai/dsh-session' { * @param payload - opaque test payload */ 'telemetry-test/opaque': { payload: { nested: string[] } } - /** - * Test-only stand-in for dsh-compact's merge, exercising the widened severity probe. - * @mode emit - * @param error - failure text when the compaction failed - */ - 'compact/end': { turn: number; error?: string } } } @@ -104,15 +98,14 @@ describe('TelemetryCoordinator capture', () => { } }) - it('maps outcome flags to severity, including the widened merge-extensible probe', async () => { + it('maps outcome flags to severity, unknown types falling through as info', async () => { const { ctx, backend } = await setup() const session = liveSession(ctx) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('tool/result', { turn: 1, step: 1, callId: 'c1' as never, content: [], isError: true }, { surfaceOp: 'append' }) session.append('tool/result', { turn: 1, step: 1, callId: 'c2' as never, content: [], isError: false }, { surfaceOp: 'append' }) session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) - session.append('compact/end', { turn: 1, error: 'summarizer died' }) - session.append('compact/end', { turn: 1 }) + session.append('telemetry-test/opaque', { payload: { nested: [] } }) session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity]) expect(severities).toEqual([ @@ -120,8 +113,7 @@ describe('TelemetryCoordinator capture', () => { ['tool/result', 'error'], ['tool/result', 'info'], ['prompt/blocked', 'warn'], - ['compact/end', 'error'], - ['compact/end', 'info'], + ['telemetry-test/opaque', 'info'], ['turn/end', 'error'], ]) }) From e6a8ff2621fa6de8685dc6e202440404b5dfb997 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 03:20:29 +0800 Subject: [PATCH 04/22] =?UTF-8?q?fix(telemetry):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20flush/shutdown=20ordering,=20session=20retirement,?= =?UTF-8?q?=20whole-exporter=20passthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, each pinned by a red test first: - The OTel backend retains the latest turn-boundary flush promise and shutdown() awaits it before provider.shutdown(): the SDK's concurrent-flush guard makes the shutdown-internal flush return early while one is in flight, silently dropping everything enqueued after the flush snapshot (including the coordinator's dispose-time shutdown markers). - The coordinator retires sessions from the adopted set on session/disposed (mirroring the persistence coordinator): a long-lived backend no longer retains closed sessions and their event logs, and final unload no longer stamps shutdown markers for dead sessions. - The exporter config passes through whole to OTLPLogExporter and its type/JSDoc now advertise the full OTLPExporterNodeConfigBase shape: rebuilding url/headers only silently dropped documented SDK options (timeoutMillis, compression, keepAlive, ...). --- docs/config-catalog.md | 15 ++-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/package.json | 1 + .../session-telemetry-otel/src/index.ts | 61 +++++++++------ .../session-telemetry-otel/tests/otel.spec.ts | 75 +++++++++++++++++-- .../telemetry/session-telemetry/README.md | 2 +- .../session-telemetry/src/coordinator.ts | 16 +++- .../telemetry/session-telemetry/src/index.ts | 10 ++- .../session-telemetry/tests/telemetry.spec.ts | 17 +++++ pnpm-lock.yaml | 3 + 12 files changed, 161 insertions(+), 45 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2f55f60bec..e017d1255a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1059,12 +1059,15 @@ Requires: `sessions` * must fail at plugin load, not at first export. */ export interface Config { - /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ - exporter?: { + /** + * 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 - /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ - headers?: Record } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -1074,9 +1077,9 @@ export interface Config { } ``` -Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:39`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:40`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b9943aaf06..bdd05ab65f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1554,7 +1554,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:125`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:129`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index da367e81f2..dcf536df7f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -34,7 +34,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../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:52`](../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:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`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:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`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:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`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:111`](../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) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index da0e96d61f..bd36c7acb3 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -15,7 +15,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th 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. Everything else is the SDK's option shape, owned and documented by the SDK; batching, retry, queue bounds, and loss policy under sustained failure are its documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. +`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. 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, retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. ## What leaves the machine diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json index 706112d1fe..7be8c04ce4 100644 --- a/packages/telemetry/session-telemetry-otel/package.json +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -30,6 +30,7 @@ "@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" diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 4738cdd932..8dde46828e 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -23,6 +23,7 @@ import { 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' @@ -37,12 +38,15 @@ const { version } = createRequire(import.meta.url)('../package.json') as { versi * must fail at plugin load, not at first export. */ export interface Config { - /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ - exporter?: { + /** + * 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 - /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ - headers?: Record } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -54,15 +58,13 @@ export interface Config { /** * 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. + * 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 = z.object({ - exporter: z.object({ - url: z.string(), - headers: z.dict(z.string()), - }), - // Opaque passthrough: the SDK owns this shape and validates its own - // options; re-declaring them here would violate the boundary axiom. + exporter: z.any(), processor: z.any(), }) @@ -112,14 +114,13 @@ export class TelemetryOtel extends Telemetry { processors: [ new BatchLogRecordProcessor({ ...config.processor, - exporter: new OTLPLogExporter({ - url, - // App identity travels in the Resource (service.name/version); - // the transport-level user-agent is the SDK's own, per the axiom. - // Schemastery fills `headers` with {} before cordis constructs the - // plugin, so the optional type exists for hand-authors only. - headers: config.exporter?.headers as Record, - }), + // 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), }), ], }) @@ -146,22 +147,34 @@ export class TelemetryOtel extends Telemetry { }) } + /** The latest turn-boundary flush, retained so {@link shutdown} can order behind it. */ + private inflightFlush: Promise = Promise.resolve() + /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ override flush(): void { // Best-effort hint: the SDK resolves forceFlush even when exports fail // (failures go to its own diagnostics), and the coordinator stops calling - // this once the fiber is disposed — a rejection would be SDK drift. + // this once the fiber is disposed — a rejection would be SDK drift. The + // settled promise is retained (not awaited): the SDK's concurrent-flush + // guard makes a flush that overlaps another return WITHOUT draining, so + // shutdown must wait this one out before trusting its own flush. /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ - void this.provider.forceFlush().catch(() => {}) + this.inflightFlush = this.provider.forceFlush().catch(() => {}) } /** * Delegate disposal to the SDK's shutdown contract: flush the queue and - * quiesce. Awaited (and error-contained) by the coordinator's disposer. + * quiesce. Orders behind the last turn-boundary flush first — shutdown's + * internal flush is a no-op while one is in flight (the SDK's + * concurrent-flush guard), which would silently drop everything enqueued + * after that flush snapshot, including the coordinator's dispose-time + * `shutdown` markers. Awaited (and error-contained) by the coordinator's + * disposer. * @returns resolves when the SDK pipeline has quiesced. */ - shutdown(): Promise { - return this.provider.shutdown() + async shutdown(): Promise { + await this.inflightFlush + await this.provider.shutdown() } } diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 88a09a0f3b..b44b42ea04 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -8,6 +8,7 @@ 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' @@ -43,17 +44,26 @@ afterEach(async () => { } }) -async function mockCollector(): Promise<{ url: string; captures: Capture[] }> { +async function mockCollector( + beforeRespond?: (requestIndex: number) => Promise | 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', () => { - captures.push({ - headers: request.headers, - body: JSON.parse(Buffer.concat(chunks).toString()) as OtlpLogsRequest, - }) - response.writeHead(200, { 'content-type': 'application/json' }).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) @@ -113,6 +123,59 @@ describe('TelemetryOtel wire', () => { expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) }) + it('delivers records enqueued while a turn-boundary flush is in flight (flush/shutdown race)', async () => { + // Hold the collector's response to the flush-triggered export open until + // after disposal has begun: the SDK's concurrent-flush guard makes the + // shutdown-internal flush return early while another flush is running, so + // without ordering in the backend the coordinator's dispose-time shutdown + // marker (enqueued after the flush snapshot) would be dropped silently. + const gate = Promise.withResolvers() + const arrived = Promise.withResolvers() + const { url, captures } = await mockCollector(async (index) => { + if (index === 0) { + arrived.resolve(true) + await gate.promise + } + }) + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('race'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + ctx.telemetry.flush!() + 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 records = allRecords(captures) + const ops = records.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 the warn severity and forwards the flush hint to the SDK', async () => { const { url, captures } = await mockCollector() const { ctx, fiber } = await boot(url) diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 26a28baa2d..80667b308f 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -8,7 +8,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## 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 — seed events from fork/resume never re-emit on the firehose), `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), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per adopted session emit its `shutdown` operational record, 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 coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `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` (retire: release the adopted entry so a long-lived backend neither retains closed sessions nor stamps dispose-time markers for them), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per still-adopted session emit its `shutdown` operational record, 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 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index ba1610247d..e8213e588a 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -35,13 +35,20 @@ const handoffCursor = new WeakMap() * 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`). Disposal emits each adopted session's `shutdown` + * `session/created`). A `session/disposed` retires the session from the + * adopted set — a long-lived backend must not retain closed sessions (and + * their frozen event logs) or stamp dispose-time markers for sessions that + * already ended. Disposal emits each still-adopted session's `shutdown` * operational record 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, for dispose-time `shutdown` records and double-adoption protection. */ + /** + * Sessions adopted by THIS fiber and still live, for dispose-time + * `shutdown` records and double-adoption protection; `session/disposed` + * retires entries. + */ private readonly adopted = new Set() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ private readonly chunkSeen = new WeakMap>() @@ -57,6 +64,11 @@ export class TelemetryCoordinator { ctx.on('session/created', (session) => { this.adopt(session) }) + // Retirement is observe-only: the projection/cursor WeakMaps die with the + // Session object; only the strong adopted set needs the explicit release. + ctx.on('session/disposed', (session) => { + this.adopted.delete(session) + }) ctx.on('session/event', (session, event) => { this.contain(() => { this.capture(session, event) diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 09800aa1b5..6d92a9547d 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -108,9 +108,13 @@ export interface TelemetryBackend { flush?(): void /** * Forward the fiber's disposal to the SDK: flush whatever is queued and - * reach quiescence, per the SDK's own shutdown contract. Awaited by the - * coordinator's dispose; a rejection is logged as a warning and never - * fails application teardown. + * 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 diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 743e90073d..55b5ed694a 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -262,6 +262,23 @@ describe('TelemetryCoordinator lifecycle and containment', () => { expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true) }) + it('retires a disposed session: no retention, no stale shutdown marker at unload', async () => { + const { ctx, backend, fiber } = await setup() + liveSession(ctx, 'survivor') + // A session owned by its own fiber: disposing the fiber detaches it from + // the store and emits `session/disposed` — the authoritative retirement + // signal a long-lived telemetry backend must honor, or every closed + // session (and its full event log) stays strongly held for the backend's + // lifetime and final unload emits shutdown markers for dead sessions. + const owner = await ctx.plugin(Object.assign((inner: Context) => { + inner.sessions.create(SessionId('ephemeral'), { meta: {} }) + }, { inject: ['sessions'] })) + await owner.dispose() + await fiber.dispose() + const ops = backend.records.filter(r => r.channel === 'ops') + expect(ops.map(r => r.attributes['session.id'])).toEqual(['survivor']) + }) + it('warns instead of throwing when backend shutdown fails', async () => { const backend = new FakeBackend() backend.shutdownError = new Error('exporter unreachable') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e96b9cf4d..7b82b3bf3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3567,6 +3567,9 @@ importers: '@opentelemetry/exporter-logs-otlp-http': specifier: ^0.220.0 version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': specifier: ^2.9.0 version: 2.10.0(@opentelemetry/api@1.9.1) From d398eda43222b8a64b3afb733f2e37646ec9300f Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 03:48:32 +0800 Subject: [PATCH 05/22] fix(telemetry): join overlapping flush hints; contain adoption replay per event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round, both pinned red-first: - Overlapping turn-boundary flush hints now JOIN the outstanding flush promise (Promise.all) instead of displacing it: the SDK's concurrent-flush guard resolves an overlapping forceFlush() immediately, so retaining only the latest promise let shutdown() proceed while the first export was still in flight — the same silent drop the single-flush fix closed. - Adoption replay contains failures per event, matching the firehose: one rejected record is withheld fail-closed while the rest of the historical log still hands off. Wrapping the whole loop let a single failure silently skip the remainder on an already-adopted session. --- .../session-telemetry-otel/src/index.ts | 12 ++++--- .../session-telemetry-otel/tests/otel.spec.ts | 33 +++++++++++++++++++ .../session-telemetry/src/coordinator.ts | 18 ++++++---- .../session-telemetry/tests/telemetry.spec.ts | 26 +++++++++++++++ 4 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 8dde46828e..d415d1a7e8 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -147,7 +147,7 @@ export class TelemetryOtel extends Telemetry { }) } - /** The latest turn-boundary flush, retained so {@link shutdown} can order behind it. */ + /** Every not-yet-settled turn-boundary flush, retained so {@link shutdown} can order behind ALL of them. */ private inflightFlush: Promise = Promise.resolve() /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ @@ -157,15 +157,17 @@ export class TelemetryOtel extends Telemetry { // this once the fiber is disposed — a rejection would be SDK drift. The // settled promise is retained (not awaited): the SDK's concurrent-flush // guard makes a flush that overlaps another return WITHOUT draining, so - // shutdown must wait this one out before trusting its own flush. + // an overlapping hint resolves instantly and must JOIN the outstanding + // one, not displace it — shutdown orders behind the whole set. /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ - this.inflightFlush = this.provider.forceFlush().catch(() => {}) + const flush = this.provider.forceFlush().catch(() => {}) + this.inflightFlush = Promise.all([this.inflightFlush, flush]).then(() => undefined) } /** * Delegate disposal to the SDK's shutdown contract: flush the queue and - * quiesce. Orders behind the last turn-boundary flush first — shutdown's - * internal flush is a no-op while one is in flight (the SDK's + * quiesce. Orders behind every outstanding turn-boundary flush first — + * shutdown's internal flush is a no-op while one is in flight (the SDK's * concurrent-flush guard), which would silently drop everything enqueued * after that flush snapshot, including the coordinator's dispose-time * `shutdown` markers. Awaited (and error-contained) by the coordinator's diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index b44b42ea04..85bfedeb1d 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -155,6 +155,39 @@ describe('TelemetryOtel wire', () => { expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) }) + it('orders shutdown behind the OLDEST in-flight flush when hints overlap', async () => { + // The SDK's concurrent-flush guard resolves an overlapping forceFlush() + // immediately; if the backend RETAINS only the latest flush promise, two + // back-to-back turn flushes leave shutdown awaiting the instantly-resolved + // second one while the first still exports — reopening the same silent + // drop the single-flush race test pins. + const gate = Promise.withResolvers() + const arrived = Promise.withResolvers() + const { url, captures } = await mockCollector(async (index) => { + if (index === 0) { + arrived.resolve(true) + await gate.promise + } + }) + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('race2'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + ctx.telemetry.flush!() + await arrived.promise + // Second hint while the first export is held open: resolves immediately + // under the SDK's guard and must not displace the outstanding one. + ctx.telemetry.flush!() + + const disposal = fiber.dispose() + 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() diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index e8213e588a..027c6af09e 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -113,15 +113,19 @@ export class TelemetryCoordinator { * @param session - the live session to adopt; a second adoption is a no-op. */ private adopt(session: Session): void { - this.contain(() => { - if (this.adopted.has(session)) return - this.adopted.add(session) - const cursor = handoffCursor.get(session) ?? -1 - for (const event of session.events) { + if (this.adopted.has(session)) return + this.adopted.add(session) + const cursor = handoffCursor.get(session) ?? -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. */ diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 55b5ed694a..d52baa91f5 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -26,11 +26,15 @@ class FakeBackend implements TelemetryBackend { records: TelemetryRecord[] = [] calls: string[] = [] emitError: Error | undefined + rejectSeq: number | undefined shutdownError: Error | undefined shutdownResolved = false emit(record: TelemetryRecord): void { if (this.emitError) throw this.emitError + if (this.rejectSeq !== undefined && record.attributes['event.seq'] === this.rejectSeq) { + throw new Error(`backend rejected seq ${this.rejectSeq}`) + } this.records.push(record) this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`) } @@ -213,6 +217,28 @@ describe('TelemetryCoordinator adoption', () => { expect(second.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end']) }) + it('replays past a record the backend rejects: one event withheld, the rest adopted', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const session = liveSession(ctx, 'partial') + appendTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // The backend rejects exactly the middle historical event: fail-closed + // must withhold THAT record only — an adoption replay that dies on the + // first contained failure would silently skip the rest of the log while + // the session stays marked adopted. + backend.rejectSeq = 1 + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 2]) + expect(warn).toHaveBeenCalled() + }) + it('re-hands the full log when no cursor survived (fresh session object)', async () => { const backend = new FakeBackend() const ctx = new Context() From ec38cac8efb4a95e8cda44839cc23999ce767be8 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 21:21:45 +0800 Subject: [PATCH 06/22] fix(telemetry): emit the shutdown marker at the session's own disposal edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (Codex P1), pinned red-first: the marker was tied to telemetry-plugin lifetime, but receivers key crash detection on its absence per session. A normally closed session in a long-running host retired silently (classified as a crash once stale), while a telemetry reload marked every still-live session as cleanly ended. The session/disposed handler now emits the marker at the session's own termination edge before retiring it; the dispose-time sweep only marks sessions still alive at application teardown (their own edge would fire unobserved). READMEs restate the marker semantics: telemetry stopped observing cleanly — a marker followed by more session events is a telemetry reload, not a session restart. --- .../session-telemetry-otel/README.md | 2 +- .../telemetry/session-telemetry/README.md | 2 +- .../session-telemetry/src/coordinator.ts | 43 +++++++++++++------ .../session-telemetry/tests/telemetry.spec.ts | 36 +++++++++++++--- 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index bd36c7acb3..04fa229fc5 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -23,7 +23,7 @@ Records carry the complete `event.data` as the seam's `telemetry/redact` waterfa ## 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 staleness (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). +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. ## Model Experience diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 80667b308f..af816a6158 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -8,7 +8,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## 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 — seed events from fork/resume never re-emit on the firehose), `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` (retire: release the adopted entry so a long-lived backend neither retains closed sessions nor stamps dispose-time markers for them), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per still-adopted session emit its `shutdown` operational record, 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 coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `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 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 027c6af09e..d500a66d89 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -35,19 +35,20 @@ const handoffCursor = new WeakMap() * 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` retires the session from the - * adopted set — a long-lived backend must not retain closed sessions (and - * their frozen event logs) or stamp dispose-time markers for sessions that - * already ended. Disposal emits each still-adopted session's `shutdown` - * operational record and then awaits the backend's `shutdown()`; a failure - * there warns instead of throwing — best-effort reporting must not fail - * application teardown. + * `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 dispose-time - * `shutdown` records and double-adoption protection; `session/disposed` - * retires entries. + * 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() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ @@ -64,10 +65,17 @@ export class TelemetryCoordinator { ctx.on('session/created', (session) => { this.adopt(session) }) - // Retirement is observe-only: the projection/cursor WeakMaps die with the - // Session object; only the strong adopted set needs the explicit release. + // 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.adopted.delete(session) + this.contain(() => { + if (!this.adopted.delete(session)) return + this.handOff(shutdownRecord(session)) + }) }) ctx.on('session/event', (session, event) => { this.contain(() => { @@ -87,6 +95,10 @@ export class TelemetryCoordinator { }) }) 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)) @@ -214,7 +226,10 @@ export class TelemetryCoordinator { } } -/** Build the per-session clean-exit marker emitted at dispose, before the backend's `shutdown()`. */ +/** + * 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', diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index d52baa91f5..efbd878a25 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -275,6 +275,26 @@ describe('TelemetryCoordinator lifecycle and containment', () => { expect(backend.flush).not.toHaveBeenCalled() }) + it('emits no marker for a session whose announcement was vetoed before adoption', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + // A listener registered BEFORE the coordinator vetoes publication: the + // store still emits the paired `session/disposed` for rollback, but the + // coordinator never saw `session/created` — a marker for a session the + // receiver saw no activity from would be noise, not signal. + ctx.on('session/created', () => { + throw new Error('vetoed by an earlier listener') + }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(() => ctx.sessions.create(SessionId('vetoed'), { meta: {} })).toThrow('vetoed') + expect(backend.records.filter(r => r.channel === 'ops')).toHaveLength(0) + }) + it('emits each adopted session’s shutdown record before awaiting backend shutdown', async () => { const { ctx, backend, fiber } = await setup() liveSession(ctx, 's1') @@ -288,21 +308,25 @@ describe('TelemetryCoordinator lifecycle and containment', () => { expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true) }) - it('retires a disposed session: no retention, no stale shutdown marker at unload', async () => { + it('emits the shutdown marker at the session’s own disposal edge, then retires it', async () => { const { ctx, backend, fiber } = await setup() liveSession(ctx, 'survivor') // A session owned by its own fiber: disposing the fiber detaches it from - // the store and emits `session/disposed` — the authoritative retirement - // signal a long-lived telemetry backend must honor, or every closed - // session (and its full event log) stays strongly held for the backend's - // lifetime and final unload emits shutdown markers for dead sessions. + // the store and emits `session/disposed` — the authoritative termination + // edge. The marker must ride THAT edge (receivers classify a session with + // activity and no marker as crashed, so a normally closed session in a + // long-running host must not look like a crash), and the session retires + // from the adopted set so unload neither retains it nor re-marks it. const owner = await ctx.plugin(Object.assign((inner: Context) => { inner.sessions.create(SessionId('ephemeral'), { meta: {} }) }, { inject: ['sessions'] })) await owner.dispose() + const atEdge = backend.records.filter(r => r.channel === 'ops') + expect(atEdge.map(r => r.attributes['session.id'])).toEqual(['ephemeral']) + expect(atEdge[0]!.attributes['telemetry.op']).toBe('shutdown') await fiber.dispose() const ops = backend.records.filter(r => r.channel === 'ops') - expect(ops.map(r => r.attributes['session.id'])).toEqual(['survivor']) + expect(ops.map(r => r.attributes['session.id'])).toEqual(['ephemeral', 'survivor']) }) it('warns instead of throwing when backend shutdown fails', async () => { From 95315386b398abb563af666a31f3a91c0aa50585 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 17:55:54 +0800 Subject: [PATCH 07/22] refactor(telemetry): remove the OTel backend's flush forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review rounds each found a new silent-loss path in the same wrapper state (dispose racing an in-flight flush, overlapping hints displacing the retained promise, the provider's fixed 30s flush timeout rejecting while the processor still drains). Every path exists only because forwarding the seam's turn-boundary hint to forceFlush() made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree. The backend now implements no flush(): 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. The two race-pin tests collapse into one dispose-during-in-flight-batch drain pin; the seam's optional flush() contract now tells implementers they own the concurrent-flush/ shutdown interaction. Removal rationale and the reinstatement trigger (a stated turn-boundary latency requirement scheduledDelayMillis cannot meet — and then via the processor's own forceFlush(), never the provider's timeout-wrapped one) are recorded in the revival Agent Note, both languages. --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 2 + ...07-23-session-telemetry-otel-revival.zh.md | 2 + docs/cordis-catalog/services.md | 2 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/README.zh.md | 2 +- .../session-telemetry-otel/src/index.ts | 42 +++++------- .../session-telemetry-otel/tests/otel.spec.ts | 67 ++++++------------- .../session-telemetry/README.i18n.yaml | 4 +- .../telemetry/session-telemetry/README.md | 2 +- .../telemetry/session-telemetry/README.zh.md | 2 +- .../telemetry/session-telemetry/src/index.ts | 7 +- 13 files changed, 56 insertions(+), 86 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index e5c566662a..e17d42d2bb 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-session-telemetry-otel-revival.md: 28a88218566dcfe34a3b99b682a4c21d22e00ce2 -2026-07-23-session-telemetry-otel-revival.zh.md: 79428c5fbb76bdd8f3c2d0b856edd712b6a650d0 +2026-07-23-session-telemetry-otel-revival.md: 476073751a52989434798d4efa0013fc9908d6fa +2026-07-23-session-telemetry-otel-revival.zh.md: 78372554bc57ef298d399b7f01ba8a122964d5d5 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 28a8821856..476073751a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -28,6 +28,8 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **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. +**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/redact` 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. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 79428c5fbb..78372554bc 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -28,6 +28,8 @@ Status: implemented **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 +**将 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/redact` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a94cbc17c1..d255d1a70a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1717,7 +1717,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:129`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:134`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 6dd8b6b5fc..89ba658a6f 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 2e0c902e7b16726829319c852eab3f26a68847b3 -README.zh.md: 4917ee1fcff91f07d29bbcb1b6957b80d9e73197 +README.md: 28fffc5f43f960de1a44700aa70050d792b81d4f +README.zh.md: 821969fea09487d9d4450e981a0d791a66dad3a1 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 2e0c902e7b..28fffc5f43 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -17,7 +17,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th 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. 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, retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. +`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. 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 diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 4917ee1fcf..821969fea0 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -17,7 +17,7 @@ processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` 是本包唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。 +`exporter.url` 是本包唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。 ## 哪些数据会离开本机 diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index d415d1a7e8..5a0d62cb10 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -147,36 +147,26 @@ export class TelemetryOtel extends Telemetry { }) } - /** Every not-yet-settled turn-boundary flush, retained so {@link shutdown} can order behind ALL of them. */ - private inflightFlush: Promise = Promise.resolve() - - /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ - override flush(): void { - // Best-effort hint: the SDK resolves forceFlush even when exports fail - // (failures go to its own diagnostics), and the coordinator stops calling - // this once the fiber is disposed — a rejection would be SDK drift. The - // settled promise is retained (not awaited): the SDK's concurrent-flush - // guard makes a flush that overlaps another return WITHOUT draining, so - // an overlapping hint resolves instantly and must JOIN the outstanding - // one, not displace it — shutdown orders behind the whole set. - /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ - const flush = this.provider.forceFlush().catch(() => {}) - this.inflightFlush = Promise.all([this.inflightFlush, flush]).then(() => undefined) - } + // 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: flush the queue and - * quiesce. Orders behind every outstanding turn-boundary flush first — - * shutdown's internal flush is a no-op while one is in flight (the SDK's - * concurrent-flush guard), which would silently drop everything enqueued - * after that flush snapshot, including the coordinator's dispose-time - * `shutdown` markers. Awaited (and error-contained) by the coordinator's - * disposer. + * 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. */ - async shutdown(): Promise { - await this.inflightFlush - await this.provider.shutdown() + shutdown(): Promise { + return this.provider.shutdown() } } diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 85bfedeb1d..9069ec89da 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -123,12 +123,14 @@ describe('TelemetryOtel wire', () => { expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) }) - it('delivers records enqueued while a turn-boundary flush is in flight (flush/shutdown race)', async () => { - // Hold the collector's response to the flush-triggered export open until - // after disposal has begun: the SDK's concurrent-flush guard makes the - // shutdown-internal flush return early while another flush is running, so - // without ordering in the backend the coordinator's dispose-time shutdown - // marker (enqueued after the flush snapshot) would be dropped silently. + 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() const arrived = Promise.withResolvers() const { url, captures } = await mockCollector(async (index) => { @@ -137,10 +139,14 @@ describe('TelemetryOtel wire', () => { await gate.promise } }) - const { ctx, fiber } = await boot(url) - const session = ctx.sessions.create(SessionId('race'), { meta: {} }) + 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' } } }) - ctx.telemetry.flush!() await arrived.promise const disposal = fiber.dispose() @@ -149,40 +155,6 @@ describe('TelemetryOtel wire', () => { gate.resolve(true) await disposal - const records = allRecords(captures) - const ops = records.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('orders shutdown behind the OLDEST in-flight flush when hints overlap', async () => { - // The SDK's concurrent-flush guard resolves an overlapping forceFlush() - // immediately; if the backend RETAINS only the latest flush promise, two - // back-to-back turn flushes leave shutdown awaiting the instantly-resolved - // second one while the first still exports — reopening the same silent - // drop the single-flush race test pins. - const gate = Promise.withResolvers() - const arrived = Promise.withResolvers() - const { url, captures } = await mockCollector(async (index) => { - if (index === 0) { - arrived.resolve(true) - await gate.promise - } - }) - const { ctx, fiber } = await boot(url) - const session = ctx.sessions.create(SessionId('race2'), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - ctx.telemetry.flush!() - await arrived.promise - // Second hint while the first export is held open: resolves immediately - // under the SDK's guard and must not displace the outstanding one. - ctx.telemetry.flush!() - - const disposal = fiber.dispose() - 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' } }) @@ -209,15 +181,14 @@ describe('TelemetryOtel wire', () => { expect(types).toContain('turn/start') }) - it('maps the warn severity and forwards the flush hint to the SDK', async () => { + it('maps the warn severity and leaves the seam flush hint unimplemented', async () => { const { url, captures } = await mockCollector() const { ctx, fiber } = await boot(url) const session = ctx.sessions.create(SessionId('warn'), { meta: {} }) session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) - // The turn-boundary hint: safe, non-blocking, and enough to push the batch out. - expect(() => { - ctx.telemetry.flush!() - }).not.toThrow() + // 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 blocked = allRecords(captures).find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'prompt/blocked')) diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index 49f06c4826..dd0da745e8 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 73bc22053ad08725fecade43fb33fe944dfe3c60 -README.zh.md: 8e63e14bd8e099980133c9d6d864135fba7f39af +README.md: df0384d0528f0b3a95ded414444e1b11ea7d52bf +README.zh.md: d86fede206a4b2363f1deda86ff32d868d1db71d diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 73bc22053a..df0384d052 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -6,7 +6,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## 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), and `shutdown()` (the lifecycle forward: flush-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor. +`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), 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 diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index 8e63e14bd8..d86fede206 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -6,7 +6,7 @@ ## 后端契约 -`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果)、以及 `shutdown()`(生命周期转发点:flush 并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端在其构造函数中组合 `TelemetryCoordinator`。 +`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端在其构造函数中组合 `TelemetryCoordinator`。 ## 捕获点 diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 6d92a9547d..b52f80fbbe 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -103,7 +103,12 @@ export interface TelemetryBackend { * 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). + * 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 /** From e56afd718d324009712efc67bd02cf204d257b24 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 13:45:04 +0800 Subject: [PATCH 08/22] test(pty-local): keep the raw-mode send active until python3 prints its marker The darwin-parity job failed intermittently on the SIGINT test with the operation buffer holding only the echoed command line, never RAW_READY. The harness sets idleSilenceMs to 250, so when a cold python3 start stays silent past that bound the send settles as inferred_idle; PtySendOperation.append then drops all later output, and the marker reaches only the scrollback. Give the harness per-test idleSilenceMs/timeoutMs overrides and let this scenario raise both above interpreter startup latency, so the readiness marker lands inside the send it belongs to. waitForOutput's own deadline and the test timeout grow to match the new bounds. The product timings are unchanged; the pty Agent Note records why a test that waits on an operation must outlast the child's startup. --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 6 ++--- .../2026-07-16-persistent-pty-sessions.md | 2 ++ .../2026-07-16-persistent-pty-sessions.zh.md | 2 ++ packages/pty/pty-local/tests/local.spec.ts | 23 ++++++++++++++----- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index f7e242b78c..a6877c35ab 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-16-persistent-pty-sessions.md: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95 -2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +2026-07-16-persistent-pty-sessions.md: 72d903828f8152009a2dd3433d4f7c86b7382ba6 +2026-07-16-persistent-pty-sessions.zh.md: 27dce02618ab7908f38a6b613c109fcbb4b3854c diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 148d4a2f47..72d903828f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -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 reaches only the scrollback. A test that waits for a marker on the operation 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 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 9a9d9cd4b0..27dce02618 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -80,6 +80,8 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 ` Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 +一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出只会进入 scrollback。因此,在 operation 上等待标记的测试必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。 + `node-pty` data 通知进入同一个终端 parser。parser 的 carry state 会处理跨 callback 的控制序列和位于 callback 末尾的回车;因此,即使 CRLF 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。 ### 模型可见输出与持久性 diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 6ba3a95757..2f60cf1918 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { } } -async function harness(mode: 'danger-full-access' | 'workspace-write') { +async function harness( + mode: 'danger-full-access' | 'workspace-write', + overrides: { idleSilenceMs?: number; timeoutMs?: number } = {}, +) { const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-')) roots.push(root) const ctx = new Context() @@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { const fiber = await ctx.plugin(ptyLocal, { pollIntervalMs: 10, exactProbeAfterMs: 20, - idleSilenceMs: 250, - timeoutMs: 2000, + idleSilenceMs: overrides.idleSilenceMs ?? 250, + timeoutMs: overrides.timeoutMs ?? 2_000, disposeGraceMs: 500, scrollbackLines: 100, scrollbackMaxBytes: 32_768, @@ -63,8 +66,10 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { 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 the send is still active. async function waitForOutput(operation: PtySendOperation, expected: string): Promise { - const deadline = Date.now() + 2_000 + const deadline = Date.now() + 5_000 let output = '' while (!output.includes(expected) && Date.now() < deadline) { output += operation.readOutput().delta @@ -132,7 +137,13 @@ describe('pty-local real shell', () => { }, 10_000) it('cancels a raw-mode foreground process with a real SIGINT', async () => { - const { ctx, agent } = await harness('danger-full-access') + // A cold `python3` start can stay silent for longer than the 250 ms default + // this harness uses, which settles the send as inferred_idle before the + // interpreter prints its readiness marker; the marker then reaches only the + // scrollback and waitForOutput sees the echoed command line alone. Raise the + // silence bound, and the absolute bound above it, so process startup cannot + // end the send it belongs to. + const { ctx, agent } = await harness('danger-full-access', { idleSilenceMs: 4_000, timeoutMs: 6_000 }) const created = await ctx.pty.spawn(agent, { type: 'shell' }) const controller = new AbortController() const ready = 'RAW_READY' @@ -155,5 +166,5 @@ describe('pty-local real shell', () => { expect(after.viewport).toContain('AFTER_SIGINT') expect(after.waitReason).toBe('stdin_read') await ctx.pty.kill(agent, created.sessionId) - }, 10_000) + }, 20_000) }) From ac1431e4f5c79010284bea88579a5afd952f8ff9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 17:47:48 +0800 Subject: [PATCH 09/22] fix(session-title): improve prompt bad case: when i start the session with a skill reference, llm returns XML --- packages/session-title/session-title-llm/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session-title/session-title-llm/src/index.ts b/packages/session-title/session-title-llm/src/index.ts index 672292bbf6..673b50311a 100644 --- a/packages/session-title/session-title-llm/src/index.ts +++ b/packages/session-title/session-title-llm/src/index.ts @@ -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') From 45c7fe4ebe2afd1a37edef6802b062245c43492c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 18:29:52 +0800 Subject: [PATCH 10/22] docs(pty): re-record the note pair after the master merge --- .../feature/2026-07-16-persistent-pty-sessions.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 4c73d590af..00815d6a5d 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-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: 598aaeb034339bec956b4566aa08362c7cbba723 +2026-07-16-persistent-pty-sessions.zh.md: 4f207ee9c8d04a577019a565384f4b662636968b From 6adb14b56d0d550358f42b9073429af11aafba7e Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 18:35:03 +0800 Subject: [PATCH 11/22] =?UTF-8?q?feat(telemetry):=20adopt=20from=20the=20c?= =?UTF-8?q?onstruction=20boundary=20=E2=80=94=20constructor=20seeds=20neve?= =?UTF-8?q?r=20re-export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cursor-less adoption (process restart + resume, fork, seam-module reload) replayed the session's full log from seq 0, re-exporting history that already left the process — a resume re-billed its entire stored log on every restart, and a fork re-shipped the parent's prefix under the child's id, doubling query-time counts on OTLP backends with no native ingest dedupe. dsh-session now exposes the fact the constructor already validated but discarded: Session.firstLiveSeq, the constructor-seed length — the first seq appended in this process. header.seedLength cannot serve here: it is the durable fork-lineage boundary, and a resumed session's constructor seed is its full stored log while the header keeps the original fork value (llm-replay and session-query-sqlite depend on that meaning). Constructor seeds also never publish on the session/event firehose, so adoption replaying them was inconsistent with the system's own publication semantics. Adoption's cursor-less fallback starts at firstLiveSeq; seed events still feed the chunk projection, so mid-step continuations re-drop after a resume. Fork streams are no longer self-contained: records now carry session.seed_length (with the existing session.parent_id) so receivers stitch the child's stream onto the parent's. Accepted cost, consistent with at-most-once delivery and recorded in the revival Agent Note: a resume no longer backfills records a previous process failed to deliver — a deployment with that requirement needs the deferred outbox, not replay. Pinned red-first: seeded adoption exports nothing (assertion reversed from the prior seed-readback test, obsolete behavior changed with its test), resume-shaped seed rebuilds the projection without exporting, and fork records carry the stitch attributes. --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 2 + ...07-23-session-telemetry-otel-revival.zh.md | 2 + docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 12 ++++ docs/core-data-structures/session.zh.md | 12 ++++ .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/session/src/index.ts | 14 ++++ .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/README.zh.md | 2 +- .../session-telemetry/README.i18n.yaml | 4 +- .../telemetry/session-telemetry/README.md | 6 +- .../telemetry/session-telemetry/README.zh.md | 6 +- .../session-telemetry/src/coordinator.ts | 23 +++++-- .../session-telemetry/tests/telemetry.spec.ts | 64 +++++++++++++++++-- 17 files changed, 134 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index e17d42d2bb..9accad167a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-session-telemetry-otel-revival.md: 476073751a52989434798d4efa0013fc9908d6fa -2026-07-23-session-telemetry-otel-revival.zh.md: 78372554bc57ef298d399b7f01ba8a122964d5d5 +2026-07-23-session-telemetry-otel-revival.md: f790bafe07caa6da00621f395de62909be9ee238 +2026-07-23-session-telemetry-otel-revival.zh.md: 9b2cf657541cc08ba238c5b5d952f8908e825537 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 476073751a..f790bafe07 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -28,6 +28,8 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **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. + **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 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 78372554bc..9b2cf65754 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -28,6 +28,8 @@ Status: implemented **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 +**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:收养现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。 + **将 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 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d255d1a70a..eb8bca7c15 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1342,7 +1342,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:606`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:620`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 454bd17c38..773a436b94 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -session.md: d789ffcabb5cb0c744e265b61e322831c1d8a04f -session.zh.md: f4f102861db7403520e9f38cb56613e430718cbe +session.md: ae388f8de3a8b963a662cbe8d8736ab42ad378f9 +session.zh.md: cac769fc5c02952eefd5a01845dbb44d60e99ece diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d789ffcabb..ae388f8de3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -370,6 +370,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 diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index f4f102861d..cac769fc5c 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -372,6 +372,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 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e1dad3ed89..b88404a94b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1935,7 +1935,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(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\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(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAvailability', diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5a6b274b3e..f502bbd92a 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -300,6 +300,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 @@ -332,6 +345,7 @@ export class Session { this.log.push(deepFreeze(snapshot)) } } + this.firstLiveSeq = this.log.length this.header = snapshotSessionHeader(id, header) } diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 89ba658a6f..c53fa68694 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 28fffc5f43f960de1a44700aa70050d792b81d4f -README.zh.md: 821969fea09487d9d4450e981a0d791a66dad3a1 +README.md: 8dd2c4f6535d6d3ea772f7669185f62c4694ca43 +README.zh.md: 42bc9b7ddbbf6d9cb104de35d9489e6e583e1df7 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 28fffc5f43..8dd2c4f653 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -25,7 +25,7 @@ Records carry the complete `event.data` as the seam's `telemetry/redact` waterfa ## 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. +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`. ## Model Experience diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 821969fea0..42bc9b7ddb 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ ## 字段映射 -seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重、按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose(资源释放)时发出,对于届时仍在运行的会话,则在应用拆卸时发出;标记之后又出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。 +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` 拼接。 ## 模型体验 diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index dd0da745e8..6c905a5ea1 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: df0384d0528f0b3a95ded414444e1b11ea7d52bf -README.zh.md: d86fede206a4b2363f1deda86ff32d868d1db71d +README.md: 76f9b242ca0b4779a9ab10e5ae93bab1ffb5e581 +README.zh.md: d930dc92e41e85c597aaeff840e6e6b03712b06d diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index df0384d052..76f9b242ca 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -10,7 +10,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## 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 — seed events from fork/resume never re-emit on the firehose), `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 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 @@ -18,7 +18,7 @@ Every record passes the `telemetry/redact` waterfall between projection and `emi ## The handoff cursor -A module-scope `WeakMap` 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 full re-hand, absorbed by receiver-side dedupe on `(session.id, event.seq)`. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. +A module-scope `WeakMap` 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 @@ -26,7 +26,7 @@ Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are drop ## The logical record -`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError` and `turn/end` error reasons; WARN for `prompt/blocked`; INFO otherwise, including plugin-merged event types whose outcome semantics stay with their owners), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` 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. 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)`. +`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError` and `turn/end` error reasons; WARN for `prompt/blocked`; INFO otherwise, including plugin-merged event types whose outcome semantics stay with their owners), 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. 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 diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index d86fede206..d930dc92e4 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -10,7 +10,7 @@ ## 捕获点 -协调器的全部注册都经由组合方 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`)。 +协调器的全部注册都经由组合方 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 @@ -18,7 +18,7 @@ ## handoff 游标 -一个模块作用域的 `WeakMap` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进。游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为全量重新交接,由接收端基于 `(session.id, event.seq)` 的去重吸收。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 +一个模块作用域的 `WeakMap` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进。游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 ## 固定分片投影 @@ -26,7 +26,7 @@ ## 逻辑记录 -`TelemetryRecord` 包含:`channel`(`ledger` | `ops`)、`time`(epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError` 与 `turn/end` 的错误原因映射为 ERROR,`prompt/blocked` 映射为 WARN,其余为 INFO,包括结果语义仍归其所有者的插件合并事件类型)、只含身份信息的 `attributes`(`session.id`、`event.type`、`event.seq`,header 中存在时再加 `session.cwd`/`session.parent_id`),以及作为 `body` 的完整深拷贝 `event.data`,且以脱敏后的内容为准。运维记录携带 `telemetry.op`(`agent-error` | `shutdown`)和 `session.id`,并刻意不带 `event.seq`/`event.type`:它们是用来告警的信号,不是用来累加的条目。交接之后的投递由后端 SDK 负责;重复仍然可能出现(无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。 +`TelemetryRecord` 包含:`channel`(`ledger` | `ops`)、`time`(epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError` 与 `turn/end` 的错误原因映射为 ERROR,`prompt/blocked` 映射为 WARN,其余为 INFO,包括结果语义仍归其所有者的插件合并事件类型)、只含身份信息的 `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`:它们是用来告警的信号,不是用来累加的条目。交接之后的投递由后端 SDK 负责;重复仍然可能出现(无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。 ## 模型体验 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index d500a66d89..a5b552c765 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -117,17 +117,23 @@ export class TelemetryCoordinator { /** * Adopt a session: replay its log THROUGH the projection from the handoff - * cursor (or from the start when no cursor survived), then rely on the - * firehose for everything after. Events at or below the cursor 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. + * 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) ?? -1 + 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 @@ -264,8 +270,11 @@ function identityOf(session: Session, event: SessionEvent): Record { }) describe('TelemetryCoordinator adoption', () => { - it('reads seeded events back at adoption (fork/resume seeds never re-emit)', async () => { + it('starts export at the construction boundary: seeded history never re-exports', async () => { const backend = new FakeBackend() const ctx = new Context() await ctx.plugin(SessionStore) const parent = liveSession(ctx, 'seed-parent') appendTurn(parent) - ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} }) + const child = ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} }) await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), }) + // The live parent (no constructor seed) replays in full; the child's + // inherited prefix already left the process under another identity (the + // parent's id here; the same id in a previous process for a resume) and + // must not be re-exported — only its live suffix ships. const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]) - expect(seqs).toEqual(expect.arrayContaining([ - ['seed-parent', 0], ['seed-parent', 1], - ['seeded', 0], ['seeded', 1], - ])) + expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]])) + expect(seqs.filter(([id]) => id === 'seeded')).toEqual([]) + child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])) + .toEqual(expect.arrayContaining([['seeded', 2]])) + }) + + it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const donor = ctx.sessions.create(SessionId('donor'), { meta: {} }) + donor.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) + const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + const ofResumed = () => backend.ledger() + .filter(r => r.attributes['session.id'] === 'resumed') + .map(r => r.attributes['event.seq']) + expect(ofResumed()).toEqual([]) + // The seed fed the projection: the (turn 1, step 1) first chunk already + // shipped from the original process, so its continuation is re-dropped… + resumed.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'continuation' } }) + expect(ofResumed()).toEqual([]) + // …while a new step's first chunk exports normally. + resumed.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'next step' } }) + expect(ofResumed()).toEqual([3]) + }) + + it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const parent = liveSession(ctx, 'stitch-parent') + appendTurn(parent) + const child = ctx.sessions.create(SessionId('stitch-child'), { + seed: [...parent.events], + meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 }, + }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const record = backend.ledger().find(r => r.attributes['session.id'] === 'stitch-child')! + expect(record.attributes['session.parent_id']).toBe('stitch-parent') + expect(record.attributes['session.seed_length']).toBe(2) }) it('adopts exactly once when created fires after the sweep', async () => { From 153157195cf8bc5364b544c5f2c5457f8c09e454 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 18:47:23 +0800 Subject: [PATCH 12/22] docs(pty): scope the settle boundary to the settled operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paragraph claimed later child output reaches only the scrollback. Output after a settle still reaches the scrollback and whichever send is active when it arrives — appendOutput writes both (session.ts:302-307), and settleActive clears `active` rather than suppressing delivery. Only the settled operation stops receiving it, which is what the timing bound protects. --- .../feature/2026-07-16-persistent-pty-sessions.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-16-persistent-pty-sessions.md | 2 +- .../feature/2026-07-16-persistent-pty-sessions.zh.md | 2 +- packages/pty/pty-local/tests/local.spec.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 00815d6a5d..34e6c14df6 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 598aaeb034339bec956b4566aa08362c7cbba723 -2026-07-16-persistent-pty-sessions.zh.md: 4f207ee9c8d04a577019a565384f4b662636968b +2026-07-16-persistent-pty-sessions.md: 4fff1742721fa13ea11f1b8ec833e5e5b7e68df8 +2026-07-16-persistent-pty-sessions.zh.md: 88e7a0bc4b8af21dc51b6a654a1c9f93760b3e81 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 598aaeb034..4fff174272 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -80,7 +80,7 @@ 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 reaches only the scrollback. A test that waits for a marker on the operation 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. +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. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 4f207ee9c8..88e7a0bc4b 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -80,7 +80,7 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 ` Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 -一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出只会进入 scrollback。因此,在 operation 上等待标记的测试必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。 +一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation;它仍然会进入 scrollback,以及此时恰好处于活跃状态的任何 send。因此,等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。 `node-pty` data 通知进入同一个终端 parser。parser 的 carry state 会处理跨 callback 的控制序列和位于 callback 末尾的回车;因此,即使 CRLF 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。 diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 82795b5518..f3ed6675e7 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -67,9 +67,9 @@ async function harness( } // PtySendOperation.append drops output once the operation settles, so this only -// observes a marker the child prints while the send is still active. A caller +// 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 send never collected. +// extending this deadline alone cannot recover output the operation never collected. async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise { const deadline = Date.now() + timeoutMs let output = '' From fef928a7e1799048d9452ade918617a88272954e Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:49:49 -0700 Subject: [PATCH 13/22] feat(gui): add native workspace folder picker --- ...ative-workspace-directory-picker.i18n.yaml | 6 + ...07-27-native-workspace-directory-picker.md | 51 +++++++ ...27-native-workspace-directory-picker.zh.md | 51 +++++++ apps/web/tests/slash-flow.snapshot.ts | 1 - apps/web/tests/support.ts | 1 - apps/web/tests/workspace-flow.snapshot.ts | 3 +- apps/web/tests/workspace-management.e2e.ts | 44 +++--- .../client/connection/src/client/fixture.ts | 2 + packages/client/connection/src/index.ts | 12 +- .../connection/src/native-dialog-request.ts | 52 +++++++ packages/client/connection/tests/fake-api.ts | 3 + .../connection/tests/http-bridge.spec.ts | 47 ++++++ .../tests/native-dialog-request.spec.ts | 40 ++++++ .../client/connection/tests/node-half.spec.ts | 18 +++ packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/workspaces/service.ts | 24 +++- packages/client/runtime/tests/fake-api.ts | 3 + .../runtime/tests/workspaces-service.spec.ts | 26 +++- .../ui-conversation/src/client/apply.ts | 23 ++- .../src/client/contract/slots.ts | 2 +- .../src/client/skeleton/ConversationRoot.tsx | 28 +++- .../ui-conversation/tests/skeleton.spec.tsx | 38 ++++- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 6 +- packages/client/ui-workspace/README.zh.md | 6 +- .../src/client/WorkspaceBrowser.tsx | 2 + .../src/client/WorkspacePicker.tsx | 101 +++++++------ .../ui-workspace/src/client/contract/slots.ts | 4 + .../client/ui-workspace/src/client/index.ts | 2 + .../client/ui-workspace/tests/apply.spec.ts | 9 +- .../tests/workspace-browser.spec.tsx | 1 + .../tests/workspace-picker.spec.tsx | 88 +++++++++--- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 3 + packages/host/apiproxy/README.zh.md | 3 + packages/host/apiproxy/src/api-proxy.ts | 23 +++ packages/host/apiproxy/src/api/host.schema.ts | 8 ++ packages/host/apiproxy/src/api/host.ts | 6 + packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/fetch/client.ts | 25 +++- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../apiproxy/src/native-directory-picker.ts | 136 ++++++++++++++++++ .../tests/api-proxy-workspace.spec.ts | 45 ++++++ .../apiproxy/tests/client-handler.spec.ts | 6 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 41 +++++- .../tests/native-directory-picker.spec.ts | 58 ++++++++ 46 files changed, 910 insertions(+), 152 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md create mode 100644 packages/client/connection/src/native-dialog-request.ts create mode 100644 packages/client/connection/tests/http-bridge.spec.ts create mode 100644 packages/client/connection/tests/native-dialog-request.spec.ts create mode 100644 packages/host/apiproxy/src/native-directory-picker.ts create mode 100644 packages/host/apiproxy/tests/native-directory-picker.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml new file mode 100644 index 0000000000..47416d5a30 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-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 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md new file mode 100644 index 0000000000..847b7a7c78 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md new file mode 100644 index 0000000000..e484a2ba58 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -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 会报告这项限制,而不会回退到要求用户输入路径。 +- 在受支持的本地载体之外,浏览器元数据可能有所不同。对于无法证明其满足所需本地同源上下文的请求,该端点会按设计拒绝。 diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts index 29c1d68f7a..ac47ca4cd0 100644 --- a/apps/web/tests/slash-flow.snapshot.ts +++ b/apps/web/tests/slash-flow.snapshot.ts @@ -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' }), { diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 8041df307d..5521d984da 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -45,7 +45,6 @@ export function probeFreePort(): Promise { */ export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise { 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 }) diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 0166c6a9d9..e1fe183ef1 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -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 { 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' }), { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 98a2338064..fd18d89087 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -30,9 +30,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover car let browser: Browser let page: Page let tripwire: ReturnType + 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 => { 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) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 9aa43145ed..f53f7ac22e 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index f06b2bd2f6..bc73e0e054 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -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') } diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts new file mode 100644 index 0000000000..fe91bbae2d --- /dev/null +++ b/packages/client/connection/src/native-dialog-request.ts @@ -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 + } +} diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 1c58e604ab..cfabe476e3 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -52,6 +52,8 @@ export class FakeApiClient implements IApiClient { onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -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'] = { diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts new file mode 100644 index 0000000000..4607f32bae --- /dev/null +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -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((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((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) + }) +}) diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts new file mode 100644 index 0000000000..8048f6da83 --- /dev/null +++ b/packages/client/connection/tests/native-dialog-request.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' + +function request( + remoteAddress: string, + headers: Record = { + 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) + }) + + it('rejects remote sockets and requests without matching browser metadata', () => { + expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).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', { + host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + }) +}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index e9e880cfb4..86af61ba0d 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -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) }) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b4a418628f..ec39ce9e1c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -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, diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 4cb26aa220..fe345801c6 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -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({ items: [], state: 'idle', phase: 'pending', error: null, @@ -158,10 +166,22 @@ export class WorkspacesService { */ async create(input: { name: string } | { path: string }): Promise { 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 { + 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. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index d906ff92a8..6987d82d7a 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -69,6 +69,8 @@ export class FakeApiClient implements IApiClient { onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -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> = () => Promise.resolve(ok({ items: [] })) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 210c04d896..5327066651 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -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 () => { diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7a229758b1..789f86aeb6 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1e620f0905..9086e11d7c 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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 } /** Business callbacks injected into the strict session content seat. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 5f9e91a049..780e74be4c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -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() const pickerAnchor = useRef(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({ 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) }, })} diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 9b1eac5b93..b777c85ac3 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -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 = {}): 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({ 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' }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 88638b9e35..0d78a8d648 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259 -README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951 +README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9 +README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index b5a78c30dd..edd6c2f937 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 904543a48f..f7b73dde95 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -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(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index c56de93c56..c5609b176a 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -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) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 99ed2831a6..6b173b8987 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -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: (selector: (state: WorkspaceListState) => S) => S /** Create or adopt a real Host Workspace. */ createWorkspace: (input: { name: string } | { path: string }) => Promise + /** Open the Host's native single-directory picker. */ + pickDirectory: () => Promise /** 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(null) - const [pathDraft, setPathDraft] = useState('') const [workspaceName, setWorkspaceName] = useState('') const [creating, setCreating] = useState(false) const [modalError, setModalError] = useState(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: , + disabled: pickingFolder, })), ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { - id: CREATE_WORKSPACE, - label: 'Create workspace', - icon: , - 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: , disabled: pickingFolder }, + { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, ] const closeModal = (): void => { @@ -87,12 +87,31 @@ export function WorkspaceCreateFlow({ setModalError(null) } + const openLocalFolder = (): void => { + if (pickingFolder) return + 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 (pickingFolder) return + if (id === OPEN_LOCAL_FOLDER) { + openLocalFolder() return } if (id === CREATE_NEW) { @@ -120,11 +139,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 +158,21 @@ export function WorkspaceCreateFlow({ /> {open && workspaceSnapshot.phase === 'pending' &&
Loading workspaces…
} - - + + )} > - { setPathDraft(event.target.value) }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - confirmPath() - } - }} - /> - {creating &&
Creating workspace…
} - {modalError !== null &&
{modalError}
} +
+ {folderConflict + ? 'Choose a folder with a different name.' + : modalError} +
diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 2e4c88dd6b..1f2f3aae70 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -42,6 +42,8 @@ export type WorkspaceBrowserInjected = { insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise + /** Ask the local Host to open its native single-directory picker. */ + pickDirectory: () => Promise } /** 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 + /** Ask the local Host to open its native single-directory picker. */ + pickDirectory(): Promise } /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 98adfecce3..f27448f926 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -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 diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 8961ccdb83..24434f22aa 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -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 () => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 33abdc1231..1f06055ece 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -57,6 +57,7 @@ function mount(overrides: Partial = {}) { deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), + pickDirectory: vi.fn(async () => null), ...overrides, } const view = render() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index a5510178e7..99c5e71b48 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -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,69 @@ 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((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('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 +160,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((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 +176,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((_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 +193,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 +205,7 @@ describe('WorkspacePicker', () => { render( , ) expect(screen.queryByRole('menu')).toBeNull() @@ -176,7 +218,7 @@ describe('WorkspacePicker', () => { render( , ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index bf9f1194d4..aee09384de 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: adcffaf553bb62f77e38cf8f19f5b48e8d7881e1 -README.zh.md: 957b7967c95131f3da6cd25ce395fba8db57b020 +README.md: 253c0974cc1427fb7140c332fabdccbfc049ae86 +README.zh.md: d79628ca3e1f1d06ad94a0dada16e2208af3cd2c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index adcffaf553..253c0974cc 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 957b7967c9..d79628ca3e 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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` 会给出包含解决建议的错误提示;它不会回退到自定义目录浏览器,也不会要求用户手动输入路径。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a357eee13b..eb7abab8e4 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -33,6 +33,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 @@ -194,6 +195,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 } /** The tool/call payload fields the presenter path reads. */ @@ -823,6 +826,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: { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index cb8de392f5..c7353c4360 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,3 +17,11 @@ export const hostDescribeValueSchema = z.object({ model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), }) satisfies z.ZodType>> + +/** host.pickDirectory request payload (empty object literal). */ +export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType>> + +/** host.pickDirectory response value; null means the user cancelled. */ +export const hostPickDirectoryValueSchema = z.object({ + path: z.string().nullable(), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index fd07b33ced..e3573346f4 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -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> } diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 68ccc9ec89..768c93ed2f 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -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'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 8762670cd7..445bb0d563 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -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>> + pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> } workspace: { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> @@ -90,6 +91,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType { - const timeout = AbortSignal.timeout(this.timeoutMs) + private async postJson( + path: string, + body: ClientRequest | ClientResponse, + signal: AbortSignal | undefined, + useDefaultTimeout = true, + ): Promise { + 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, signal?: AbortSignal, + useDefaultTimeout = true, ): Promise>> { 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'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 3bbcbffba1..9b7bb5edda 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -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) }, diff --git a/packages/host/apiproxy/src/native-directory-picker.ts b/packages/host/apiproxy/src/native-directory-picker.ts new file mode 100644 index 0000000000..8bc0bc99ce --- /dev/null +++ b/packages/host/apiproxy/src/native-directory-picker.ts @@ -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 { + 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}`) +} diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index bbd57cb6dc..873971e338 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -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, ) { 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', () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 38ad7a52c9..2cf82f75b2 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -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 }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 3978b2cfe4..f90fd72e8a 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -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(stream: AsyncIterable>): Promise[]> { @@ -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((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', () => { diff --git a/packages/host/apiproxy/tests/native-directory-picker.spec.ts b/packages/host/apiproxy/tests/native-directory-picker.spec.ts new file mode 100644 index 0000000000..c33b68a236 --- /dev/null +++ b/packages/host/apiproxy/tests/native-directory-picker.spec.ts @@ -0,0 +1,58 @@ +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(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() + }) + + it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => { + const run = vi.fn(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('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => { + const run = vi.fn() + .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']) + }) + + it('maps Linux cancellation to null and reports a missing desktop picker', async () => { + const cancelled = vi.fn(async () => { throw failure(1) }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: cancelled })).resolves.toBeNull() + + const missing = vi.fn(async () => { throw failure('ENOENT') }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing })) + .rejects.toThrow('install zenity or kdialog') + }) + + it('does not convert caller aborts into user cancellation', async () => { + const abort = new AbortController() + abort.abort(new Error('closed')) + const run = vi.fn(async () => { throw failure('ABORT_ERR') }) + await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed') + }) +}) From 9dd57a25c58d93c7020e774176e925129f7e8850 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 19:20:33 +0800 Subject: [PATCH 14/22] fix(ui-question): stop option rows absorbing the capped card's shortfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question composer card is capped against the viewport and scrolls its option list. `.options` is a flex column whose children defaulted to `flex-shrink: 1`, so a short seat shrank the rows before overflowing the scroll container: a row collapsed to its 42px minimum while `.optionCopy` kept the taller height its wrapped copy needs, and `align-items: center` then painted that copy outside the row's border box — over the question title above and the next row below. Measured 6.5px of spill at 900x440 on the shipped client, 10px at 380px tall, with `.options` reporting scrollHeight === clientHeight and therefore offering no scrollbar. `.option` and `.custom` now declare `flex-shrink: 0`, so the shortfall reaches the scroll container that already owns `overflow-y: auto` — the behavior the cap was designed for. Only rows whose copy wraps could reproduce this, which is why the recorded scenario now asks a question with long option descriptions; the web e2e asserts at three squeezed seat heights that every row's children stay inside its border box, guarded against holding vacuously by requiring a wrapped row and a scrolling list. --- ...tion-composer-rows-do-not-shrink.i18n.yaml | 6 ++ ...27-question-composer-rows-do-not-shrink.md | 46 ++++++++++++++ ...question-composer-rows-do-not-shrink.zh.md | 46 ++++++++++++++ apps/web/tests/question-composer.e2e.ts | 47 +++++++++++++- .../question-composer/answered.expected.md | 14 ++--- .../snapshots/question-composer/session.jsonl | 62 +++++++++---------- .../question-composer/ui.expected.md | 4 +- .../src/client/QuestionComposer.module.css | 8 +++ 8 files changed, 192 insertions(+), 41 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml new file mode 100644 index 0000000000..0a1e35ea19 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +2026-07-27-question-composer-rows-do-not-shrink.md: 519baa831b05cd41907edb6441c65c104a571a9e +2026-07-27-question-composer-rows-do-not-shrink.zh.md: f4d24bd7086735cac795223f22d49db81bf41fde diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md new file mode 100644 index 0000000000..519baa831b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md @@ -0,0 +1,46 @@ +# 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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md new file mode 100644 index 0000000000..f4d24bd708 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -0,0 +1,46 @@ +# 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.md) | 中文 + +## Problem + +提问 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`,无描述)在任何尺寸下都渲染正常的原因。 + +## Decision + +`.option` 与 `.custom` 声明 `flex-shrink: 0`。 + +在设有高度上限的卡片中,这些行是滚动内容;卡片的溢出归 `.options` 承担,它本就持有 `overflow-y: auto` 与 `min-height: 0`。把子元素固定住之后,高度不足会传导到那个滚动容器,而不再被行本身吸收,这正是该高度上限设计时想要的行为。另一种做法是允许行被压缩,但把文案约束在行内,那就必须在用户最需要阅读选项描述的尺寸上对其做裁剪或省略号处理。 + +`.header` 与 `.footer` 出于同样的原因,已在卡片层级带有 `flex-shrink: 0`;选项列表的子元素正是这条规则缺失的另一半。 + +## Alternatives considered + +**在被压缩的行内裁剪文案或加省略号(对 `.option` 设 `overflow: hidden`)。** 这样一条声明就能消除重叠,且不必重新考虑布局。之所以否决:它把一个可见缺陷换成了一个无声缺陷——行仍保持 42px,而选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。描述属于影响决策的内容,不是装饰。 + +**把 `align-items: center` 改为 `align-items: flex-start`。** 文案就只会向下生长,因此不再向上盖住标题。但这什么也没修好:被压缩的行依然会溢出到下一行上,而且这一改动会在所有尺寸下(包括常见尺寸)无声改变每个选项行的垂直对齐。 + +**移除卡片的 `max-height` 上限,使其永远不会被压缩。** 没有高度不足,就没有分配问题。之所以否决:正是这个上限保证成批提问时标题和底部操作按钮留在屏幕内;移除它会重新引入该上限本就为之存在的失败(composer 所处的容器是一个固定高度、`overflow: hidden` 的会话列,因此不设上限的卡片会连自己的提交按钮一起丢掉)。 + +**把折行文案限制为单行(对 `.description` 设 `white-space: nowrap` 加省略号)。** 行永远不会折行,因此被压缩时也永远不会溢出。否决理由与裁剪相同,此外它还为了修一个窄视口缺陷,而牺牲了空间充裕的宽视口渲染效果。 + +## Consequences + +- 被压缩的 composer 会滚动其选项列表,而不是让它互相重叠:在 900x380 处,该列表报告 `scrollHeight` 为 200、`clientHeight` 为 114,并给出滚动条;此前两者相等,不给滚动条。 +- 选项行在任何视口尺寸下都保留完整的折行文案。不裁剪、不加省略号,宽视口下的渲染保持不变(该规则仅在 flex 盒子空间不足时才生效)。 +- 由于高度不足不再被行部分吸收,卡片现在更早进入滚动状态。这正是该高度上限想要的行为,也意味着在此前只会无声画错列表的情形下,矮容器现在会显示滚动条。 +- 该场景录制的问题,比它主要测试的那次往返所需的长度更长。这个代价是有意付出的:没有折行文案,该布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。 + +## Verification + +Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440/380)上,对活动的 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` 的改动——必须执行包构建,浏览器测试通道才能看到它。 diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 46f6af7b86..6ecdb683b3 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -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( + '[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') diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index be5b958bf2..0ac91eed92 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -6,18 +6,18 @@ - 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 "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 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: 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 diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl index 43cc228253..a98f2a92f2 100644 --- a/apps/web/tests/snapshots/question-composer/session.jsonl +++ b/apps/web/tests/snapshots/question-composer/session.jsonl @@ -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"}}} diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md index 3368b373a0..934ec244d5 100644 --- a/apps/web/tests/snapshots/question-composer/ui.expected.md +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -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 diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css index 6c0c1b854c..19306bd552 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.module.css +++ b/packages/client/ui-question/src/client/QuestionComposer.module.css @@ -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; } From a5239b0f64dcdc46b30d0cfed1bbb27a39864f84 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:37:24 -0700 Subject: [PATCH 15/22] test(gui): cover native workspace picker branches --- .../tests/native-dialog-request.spec.ts | 21 ++++- .../src/client/WorkspacePicker.tsx | 2 - .../tests/workspace-picker.spec.tsx | 9 +++ .../tests/native-directory-picker.spec.ts | 81 +++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts index 8048f6da83..1a3d70dd15 100644 --- a/packages/client/connection/tests/native-dialog-request.spec.ts +++ b/packages/client/connection/tests/native-dialog-request.spec.ts @@ -1,9 +1,10 @@ +import type { IncomingHttpHeaders } from 'node:http' import { describe, expect, it } from 'vitest' import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' function request( - remoteAddress: string, - headers: Record = { + remoteAddress: string | undefined, + headers: IncomingHttpHeaders = { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', @@ -22,10 +23,14 @@ describe('native dialog request trust', () => { 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) @@ -33,8 +38,20 @@ describe('native dialog request trust', () => { 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) }) }) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 6b173b8987..dd0cf6f562 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -88,7 +88,6 @@ export function WorkspaceCreateFlow({ } const openLocalFolder = (): void => { - if (pickingFolder) return onClose() setModalKind(null) setModalError(null) @@ -109,7 +108,6 @@ export function WorkspaceCreateFlow({ } const handleSelect = (id: string): void => { - if (pickingFolder) return if (id === OPEN_LOCAL_FOLDER) { openLocalFolder() return diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 99c5e71b48..04a320139f 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -138,6 +138,15 @@ describe('WorkspacePicker', () => { 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([]) chooseItem('Create a new workspace') diff --git a/packages/host/apiproxy/tests/native-directory-picker.spec.ts b/packages/host/apiproxy/tests/native-directory-picker.spec.ts index c33b68a236..783a67a04b 100644 --- a/packages/host/apiproxy/tests/native-directory-picker.spec.ts +++ b/packages/host/apiproxy/tests/native-directory-picker.spec.ts @@ -1,3 +1,19 @@ +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() })) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + import { describe, expect, it, vi } from 'vitest' import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts' @@ -15,6 +31,19 @@ describe('native directory picker', () => { 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(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 () => { @@ -32,12 +61,44 @@ describe('native directory picker', () => { 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(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() .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(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 () => { @@ -47,6 +108,22 @@ describe('native directory picker', () => { const missing = vi.fn(async () => { throw failure('ENOENT') }) await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing })) .rejects.toThrow('install zenity or kdialog') + + const kdialogCancelled = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockRejectedValueOnce(failure(1)) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogCancelled })) + .resolves.toBeNull() + + const zenityFailed = vi.fn(async () => { throw failure(2) }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenityFailed })) + .rejects.toThrow('command failed') + + const kdialogFailed = vi.fn() + .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 () => { @@ -55,4 +132,8 @@ describe('native directory picker', () => { const run = vi.fn(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') + }) }) From a72436105b40830826dc9fe2ee000842e3aab558 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 19:49:19 +0800 Subject: [PATCH 16/22] docs(telemetry): state the crash-repair export semantics for resumed streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: synthetic turn closers written by SessionPersistence .load() at crash-resume sit below firstLiveSeq and are never exported, leaving the remote turn unbalanced. Kept deliberately — exporting a synthetic closer cannot complete a turn whose real tail records died in the crashed process's queue; it can only make an incomplete turn look closed. The OTel README now states the receiver rule (a never-closed turn on a resumed stream marks the previous process dying inside it; a later clean shutdown marker attests only to the resumed process's exit), and the revival Agent Note records why the repair suffix stays local. --- .../2026-07-23-session-telemetry-otel-revival.i18n.yaml | 4 ++-- .../feature/2026-07-23-session-telemetry-otel-revival.md | 2 +- .../feature/2026-07-23-session-telemetry-otel-revival.zh.md | 2 +- packages/telemetry/session-telemetry-otel/README.i18n.yaml | 4 ++-- packages/telemetry/session-telemetry-otel/README.md | 2 +- packages/telemetry/session-telemetry-otel/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 9accad167a..8c4c025c86 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-session-telemetry-otel-revival.md: f790bafe07caa6da00621f395de62909be9ee238 -2026-07-23-session-telemetry-otel-revival.zh.md: 9b2cf657541cc08ba238c5b5d952f8908e825537 +2026-07-23-session-telemetry-otel-revival.md: 1c855ad8d3263f5ff3343b480983f7cf14d9d4c6 +2026-07-23-session-telemetry-otel-revival.zh.md: 371e8f9fbfbdcc7c74fa5ae39406ea4bf159bc81 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index f790bafe07..1c855ad8d3 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -28,7 +28,7 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **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. +**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. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 9b2cf65754..371e8f9fbf 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -28,7 +28,7 @@ Status: implemented **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 -**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:收养现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。 +**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 那个带超时包装的版本。 diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index c53fa68694..32d10fd39d 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 8dd2c4f6535d6d3ea772f7669185f62c4694ca43 -README.zh.md: 42bc9b7ddbbf6d9cb104de35d9489e6e583e1df7 +README.md: 6ead0ec7b7053551676b482dd4c905c8cd7cc1fe +README.zh.md: 852242bfc4ac719660f31935167d2f4fa0f58f2e diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 8dd2c4f653..6ead0ec7b7 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -25,7 +25,7 @@ Records carry the complete `event.data` as the seam's `telemetry/redact` waterfa ## 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`. +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 diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 42bc9b7ddb..852242bfc4 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ ## 字段映射 -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` 拼接。 +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` 标记也只证明恢复后进程自身的退出。 ## 模型体验 From f11dc28f1f923a66fc06eba0147865cd811d101a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 19:54:55 +0800 Subject: [PATCH 17/22] test(web): record the user-bubble action row in the answered golden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The answered golden omitted the `复制` / `在新对话中分支` / `编辑` buttons that `MessageItem`'s `UserActions` renders under every user bubble, so the scenario failed against the built client. They are unconditional in the accessibility tree — the `@media (hover: hover)` rule only sets `opacity`, which does not remove a node from an aria snapshot — so there is no message-row state to stabilize before capture; the golden was simply recorded against a stale `lib/`. A partially failed `pnpm run build` had left the client bundles from before its failure current and the rest not. The Agent Note records that trap: confirm the build exited zero before refreshing a golden, and note that untracked directories under `packages/` are compiled too, so a leftover from another branch fails the build for reasons the diff does not explain. The same omission is present in the other 15 committed web goldens on master, where 9 tests across 7 files fail for this reason on a pristine tree. That is pre-existing and belongs to whoever landed `UserActions`; this commit fixes only the golden this PR already touches. --- ...026-07-27-question-composer-rows-do-not-shrink.i18n.yaml | 4 ++-- .../2026-07-27-question-composer-rows-do-not-shrink.md | 2 ++ .../2026-07-27-question-composer-rows-do-not-shrink.zh.md | 2 ++ .../tests/snapshots/question-composer/answered.expected.md | 6 ++++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml index 0a1e35ea19..8159e53e1f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md -2026-07-27-question-composer-rows-do-not-shrink.md: 519baa831b05cd41907edb6441c65c104a571a9e -2026-07-27-question-composer-rows-do-not-shrink.zh.md: f4d24bd7086735cac795223f22d49db81bf41fde +2026-07-27-question-composer-rows-do-not-shrink.md: 52f2857185f7dc150236083eb1574bcab574dc60 +2026-07-27-question-composer-rows-do-not-shrink.zh.md: a8d3a08051757d04b6df3ca2b094cd9268227040 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md index 519baa831b..52f2857185 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md @@ -44,3 +44,5 @@ The web e2e composer scenario asserts the invariant on the live composer at thre 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. + +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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md index f4d24bd708..a8d3a08051 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -44,3 +44,5 @@ Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440 在构建产物客户端上双向确认过:撤销 `flex-shrink: 0` 后该场景失败(`scrolls: false`,6.5px 溢出),恢复后通过。一次覆盖 340 种视口尺寸(420-1600 x 320-960)的独立几何遍历,从 86 种尺寸存在文案落在行盒之外,降到 0 种。 该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模组包的形式发布,因此单跑 `pnpm run build:web` 不会带上对 `QuestionComposer.module.css` 的改动——必须执行包构建,浏览器测试通道才能看到它。 + +`lib/` 陈旧会让浏览器测试通道对着一个比工作树更旧的客户端做断言,而中途失败的 `pnpm run build` 留下的正是这种状态:失败之前构建的那些包是新的,其余不是。在这种状态下刷新 golden,记录下来的是旧客户端的界面。抓取之前先确认构建以 0 退出;另需注意 `packages/` 下的未跟踪目录同样会被编译——来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。 diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 0ac91eed92..3594459176 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -7,6 +7,12 @@ - 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 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 +- 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. From bf76c52d76a5dff4b5c77cf2c8cfeb7d5f305fd9 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 20:11:30 +0800 Subject: [PATCH 18/22] fix(telemetry): reject non-positive maxExportBatchSize at plugin load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding, pinned red-first: the SDK accepts processor.maxExportBatchSize <= 0 (or fractional), but its shutdown drain then splices empty batches without consuming the queue — disposing telemetry hangs forever whenever records are queued. The constructor now rejects a non-positive-integer batch size before building the SDK processor, per the misconfiguration-fails-loud rule; everything else in the processor block remains the SDK's verbatim passthrough. --- .../telemetry/session-telemetry-otel/README.i18n.yaml | 4 ++-- packages/telemetry/session-telemetry-otel/README.md | 2 +- packages/telemetry/session-telemetry-otel/README.zh.md | 2 +- packages/telemetry/session-telemetry-otel/src/index.ts | 8 ++++++++ .../telemetry/session-telemetry-otel/tests/otel.spec.ts | 4 ++++ 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 32d10fd39d..df98e01d2e 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 6ead0ec7b7053551676b482dd4c905c8cd7cc1fe -README.zh.md: 852242bfc4ac719660f31935167d2f4fa0f58f2e +README.md: e345449033b066103fb14e546e8d9909d68fe696 +README.zh.md: 6cad58ebc1ad9bab6dc722fcf0d702f500891d32 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 6ead0ec7b7..e345449033 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -17,7 +17,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th 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. 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. +`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 diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 852242bfc4..6cad58ebc1 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -17,7 +17,7 @@ processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` 是本包唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。 +`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` 开关。 ## 哪些数据会离开本机 diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 5a0d62cb10..85dd75f275 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -106,6 +106,14 @@ export class TelemetryOtel extends Telemetry { 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, diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 9069ec89da..0cba1ccdf6 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -202,6 +202,10 @@ describe('TelemetryOtel config fails loud', () => { [{ 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) From 3e659db0b79a81bb80c28327ddab53f4552833ea Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 20:20:12 +0800 Subject: [PATCH 19/22] docs(notes): localize the Chinese Agent Note headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chinese counterpart kept its title and every section heading in English. The bilingual contract localizes heading text; only the `# Agent Note:` prefix and the `Status:` token stay verbatim, matching the renderings already used across the note corpus (问题 / 决策 / 曾考虑的替代方案 / 后果 / 验证). Pairing hashes re-recorded. --- ...27-question-composer-rows-do-not-shrink.i18n.yaml | 2 +- ...-07-27-question-composer-rows-do-not-shrink.zh.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml index 8159e53e1f..3e0a40ee72 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml @@ -3,4 +3,4 @@ # 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: 52f2857185f7dc150236083eb1574bcab574dc60 -2026-07-27-question-composer-rows-do-not-shrink.zh.md: a8d3a08051757d04b6df3ca2b094cd9268227040 +2026-07-27-question-composer-rows-do-not-shrink.zh.md: 8d268c453ba4a7c2d6f3f197ef2fd937a23b5cb8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md index a8d3a08051..8d268c453b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -1,10 +1,10 @@ -# Agent Note: Question-composer option rows are scroll content, not the slack absorber +# Agent Note: 提问 composer 的选项行是滚动内容,而非空间不足时的吸收方 Status: implemented [English](2026-07-27-question-composer-rows-do-not-shrink.md) | 中文 -## Problem +## 问题 提问 composer 的卡片会按视口设上限(`max-height: min(60vh, 520px)`),并让选项列表自行滚动,这样在成批提问时标题和底部操作按钮始终可达。但当 composer 的容器变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),选项行会互相叠在一起,也会叠到问题标题上。 @@ -12,7 +12,7 @@ Status: implemented 只有文案会折行的选项行才能复现该问题。文案单行即可容纳的行,其内容与 42px 最小高度之间尚有余量,被压缩也看不出来——这正是既有 e2e fixture(测试前置数据)(选项为 `Blue`/`Green`,无描述)在任何尺寸下都渲染正常的原因。 -## Decision +## 决策 `.option` 与 `.custom` 声明 `flex-shrink: 0`。 @@ -20,7 +20,7 @@ Status: implemented `.header` 与 `.footer` 出于同样的原因,已在卡片层级带有 `flex-shrink: 0`;选项列表的子元素正是这条规则缺失的另一半。 -## Alternatives considered +## 曾考虑的替代方案 **在被压缩的行内裁剪文案或加省略号(对 `.option` 设 `overflow: hidden`)。** 这样一条声明就能消除重叠,且不必重新考虑布局。之所以否决:它把一个可见缺陷换成了一个无声缺陷——行仍保持 42px,而选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。描述属于影响决策的内容,不是装饰。 @@ -30,14 +30,14 @@ Status: implemented **把折行文案限制为单行(对 `.description` 设 `white-space: nowrap` 加省略号)。** 行永远不会折行,因此被压缩时也永远不会溢出。否决理由与裁剪相同,此外它还为了修一个窄视口缺陷,而牺牲了空间充裕的宽视口渲染效果。 -## Consequences +## 后果 - 被压缩的 composer 会滚动其选项列表,而不是让它互相重叠:在 900x380 处,该列表报告 `scrollHeight` 为 200、`clientHeight` 为 114,并给出滚动条;此前两者相等,不给滚动条。 - 选项行在任何视口尺寸下都保留完整的折行文案。不裁剪、不加省略号,宽视口下的渲染保持不变(该规则仅在 flex 盒子空间不足时才生效)。 - 由于高度不足不再被行部分吸收,卡片现在更早进入滚动状态。这正是该高度上限想要的行为,也意味着在此前只会无声画错列表的情形下,矮容器现在会显示滚动条。 - 该场景录制的问题,比它主要测试的那次往返所需的长度更长。这个代价是有意付出的:没有折行文案,该布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。 -## Verification +## 验证 Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440/380)上,对活动的 composer 断言该运行时不变式:每个选项行的子元素都留在该行的边框盒之内。两道守卫防止该断言空洞地成立——必须至少有一行处于折行状态(这是唯一会溢出的形态),且 `.options` 必须确实处在滚动状态(证明容器确实触及了高度上限)。该场景录制的问题现在带有较长的选项描述,正是为此;没有折行文案,这条断言不可能失败。 From 4182dbdc86262876a452b3a3db8ac54dc3ee4f52 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 20:38:22 +0800 Subject: [PATCH 20/22] test(snapshot): refresh cordis-inspect-jsdoc for Session.firstLiveSeq The self-referential inspect tool surfaces Session's public API in its transcript; the construction-boundary field added in bc8512d17 now appears there. Keyless refresh; only the affected scenario re-recorded. --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index e51933cdf3..4539ba077a 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n 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(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n 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(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From b5395f9415378e32ebf4f8ce19201b2d2a7b27c9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 21:11:15 +0800 Subject: [PATCH 21/22] docs(agent-note): record that reproducing the shortfall needs a short viewport The card cap is viewport-relative (min(60vh, 520px)), so shrinking the conversation column clips the card without under-allocating it. Anything demonstrating or measuring this defect outside the e2e scenario has to change the viewport. --- .../2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml | 4 ++-- .../2026-07-27-question-composer-rows-do-not-shrink.md | 2 ++ .../2026-07-27-question-composer-rows-do-not-shrink.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml index 3e0a40ee72..2979127ed1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md -2026-07-27-question-composer-rows-do-not-shrink.md: 52f2857185f7dc150236083eb1574bcab574dc60 -2026-07-27-question-composer-rows-do-not-shrink.zh.md: 8d268c453ba4a7c2d6f3f197ef2fd937a23b5cb8 +2026-07-27-question-composer-rows-do-not-shrink.md: 2e0e9b9ca6b141a200ba53d8b6f6f0cad5f7e89d +2026-07-27-question-composer-rows-do-not-shrink.zh.md: 845d9ee883ad1c2c7abc63a01fce626be924b87f diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md index 52f2857185..2e0e9b9ca6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md @@ -45,4 +45,6 @@ Confirmed both directions against the built client: with `flex-shrink: 0` revert 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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md index 8d268c453b..845d9ee883 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -45,4 +45,6 @@ Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440 该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模组包的形式发布,因此单跑 `pnpm run build:web` 不会带上对 `QuestionComposer.module.css` 的改动——必须执行包构建,浏览器测试通道才能看到它。 +要复现这种空间不足,需要的是矮视口,而不是矮容器。高度上限为 `min(60vh, 520px)`,因此把会话列压到比卡片自身高度更矮,只会裁剪卡片,而不会让它空间不足——各行仍保持完整高度,也不会有任何溢出。凡是在 e2e 场景之外演示或测量该缺陷的手段,都必须改变视口。 + `lib/` 陈旧会让浏览器测试通道对着一个比工作树更旧的客户端做断言,而中途失败的 `pnpm run build` 留下的正是这种状态:失败之前构建的那些包是新的,其余不是。在这种状态下刷新 golden,记录下来的是旧客户端的界面。抓取之前先确认构建以 0 退出;另需注意 `packages/` 下的未跟踪目录同样会被编译——来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。 From 0af16387ae1c244d2160c2408a3e6c61a39a1a2b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:30:28 +0800 Subject: [PATCH 22/22] fix(telemetry): rename record waterfall --- ...23-session-telemetry-otel-revival.i18n.yaml | 6 +++--- ...026-07-23-session-telemetry-otel-revival.md | 4 ++-- ...-07-23-session-telemetry-otel-revival.zh.md | 4 ++-- docs/cordis-catalog/events.md | 13 +++++++------ docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../tests/fixtures/telemetry-otel.cordis.yml | 2 +- .../tests/fixtures/telemetry-redact-rule.ts | 4 ++-- packages/cordis/tool-cordis/src/api-catalog.ts | 8 ++++---- packages/telemetry/README.i18n.yaml | 6 +++--- packages/telemetry/README.md | 2 +- packages/telemetry/README.zh.md | 2 +- .../session-telemetry-otel/README.i18n.yaml | 6 +++--- .../telemetry/session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/README.zh.md | 2 +- .../session-telemetry/README.i18n.yaml | 6 +++--- packages/telemetry/session-telemetry/README.md | 4 ++-- .../telemetry/session-telemetry/README.zh.md | 4 ++-- .../session-telemetry/src/coordinator.ts | 8 ++++---- .../telemetry/session-telemetry/src/index.ts | 7 ++++--- .../session-telemetry/tests/redact.spec.ts | 18 +++++++++--------- 21 files changed, 57 insertions(+), 55 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 8c4c025c86..cd9e4f7e9f 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-23-session-telemetry-otel-revival.md: 1c855ad8d3263f5ff3343b480983f7cf14d9d4c6 -2026-07-23-session-telemetry-otel-revival.zh.md: 371e8f9fbfbdcc7c74fa5ae39406ea4bf159bc81 +# 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 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 1c855ad8d3..a58598d8a9 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -13,7 +13,7 @@ Every deployment that wants harness sessions in an observability stack must hand `packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go and owns what leaves in them: - **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. -- **The `telemetry/redact` waterfall** — the delta over the branch version. 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. +- **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. @@ -34,4 +34,4 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry ## Consequences -A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/redact` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; 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. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 371e8f9fbf..cc09717e34 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -13,7 +13,7 @@ Status: implemented `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/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 +- **`telemetry/record` waterfall** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 - **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 @@ -34,4 +34,4 @@ Status: implemented ## Consequences -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/redact` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 29cb51b717..aa53b02ff1 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -894,14 +894,15 @@ Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/syst ## `telemetry/*` -### `telemetry/redact` — waterfall +### `telemetry/record` — waterfall -Redact one outbound record before it reaches the backend — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. ```ts cordis-catalog /** - * Redact one outbound record before it reaches the backend — the seam's - * scrubbing extension point. The seam ships NO rules of its own: the + * 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 @@ -915,10 +916,10 @@ Redact one outbound record before it reaches the backend — the seam's scrubbin * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall */ -'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord +'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:40`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:41`](../../packages/telemetry/session-telemetry/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index fff219ece6..8b3326142b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1772,7 +1772,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:134`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:135`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a3c5387097..2fa8767acf 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -48,7 +48,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/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:40`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | +| `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) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index ff81d0116c..34e23b828e 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -1,7 +1,7 @@ # 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/redact waterfall — the seam itself ships no rules. +# telemetry/record waterfall — the seam itself ships no rules. - id: cli-mock-llm name: './cli-mock-llm.ts' diff --git a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts index 10b7081dd3..7a2aa7958a 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts +++ b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts @@ -3,7 +3,7 @@ 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/redact` waterfall. + * own rules on the `telemetry/record` waterfall. */ const SECRET = /sk-e2efixture[0-9]+/g @@ -22,7 +22,7 @@ export const name = 'telemetry-redact-rule' /** Mount the fixture scrub rule onto the redact waterfall. */ export function apply(ctx: Context): void { - ctx.on('telemetry/redact', (_record, next) => { + ctx.on('telemetry/record', (_record, next) => { const record = next() return { ...record, body: scrub(record.body) } }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 193743fbaa..4cecb115ea 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1280,11 +1280,11 @@ export const EVENT_API: readonly EventApiEntry[] = [ summary: 'Emitted when any prompt provider changes.', }, { - name: 'telemetry/redact', + name: 'telemetry/record', mode: 'waterfall', - signature: '\'telemetry/redact\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', - jsDoc: '/**\n * Redact one outbound record before it reaches the backend — the seam\'s\n * scrubbing extension point. The seam ships NO rules 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: 'Redact one outbound record before it reaches the backend — the seam\'s scrubbing extension point.', + 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', diff --git a/packages/telemetry/README.i18n.yaml b/packages/telemetry/README.i18n.yaml index 87f24d005e..b10509cb56 100644 --- a/packages/telemetry/README.i18n.yaml +++ b/packages/telemetry/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 3e20e3ce31de36311529eb5d91382b9bb0049ddd -README.zh.md: a7036f4f119b144aa61de1c25836c980256d4db7 +# pnpm run verify-translation-pairing --write packages/telemetry/README.md +README.md: 944cb3f9bac6169feddf8b49bc481cfbe7c6fa9d +README.zh.md: 7be8af93654565cd9fb4d8b4376e0960fd7eb72b diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index 3e20e3ce31..944cb3f9ba 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -2,7 +2,7 @@ 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/redact` waterfall (deployment-mounted 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). +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 | |---|---| diff --git a/packages/telemetry/README.zh.md b/packages/telemetry/README.zh.md index a7036f4f11..7be8af9365 100644 --- a/packages/telemetry/README.zh.md +++ b/packages/telemetry/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。整套设计归档于[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md):边界公理(harness 的职责止于 `emit()`,投递由上报 SDK 负责)、`telemetry/redact` waterfall(瀑布式事件;脱敏规则由部署方挂载,seam 自身不带任何规则)、固定分片投影、handoff 游标,以及运维记录通道。 +面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。整套设计归档于[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md):边界公理(harness 的职责止于 `emit()`,投递由上报 SDK 负责)、`telemetry/record` waterfall(瀑布式事件;脱敏规则由部署方挂载,seam 自身不带任何规则)、固定分片投影、handoff 游标,以及运维记录通道。 | 包 | 职责 | |---|---| diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index df98e01d2e..e8cafdc470 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: e345449033b066103fb14e546e8d9909d68fe696 -README.zh.md: 6cad58ebc1ad9bab6dc722fcf0d702f500891d32 +# pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry-otel/README.md +README.md: 9b208e291e77bee50d9d4fd14808268dca75f2db +README.zh.md: f36cfe74146b779c4ddb1101227cb45a06f0968c diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index e345449033..9b208e291e 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -21,7 +21,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th ## What leaves the machine -Records carry the complete `event.data` as the seam's `telemetry/redact` 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/redact` 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. +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 diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 6cad58ebc1..f36cfe7414 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -21,7 +21,7 @@ ## 哪些数据会离开本机 -记录携带完整的 `event.data`,内容以 seam 的 `telemetry/redact` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/redact` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。 +记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。 ## 字段映射 diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index 6c905a5ea1..71475fc8fe 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 76f9b242ca0b4779a9ab10e5ae93bab1ffb5e581 -README.zh.md: d930dc92e41e85c597aaeff840e6e6b03712b06d +# pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry/README.md +README.md: a3820ee8e91a5c513c08df76b149cf3a670ee2ca +README.zh.md: d607a4ab4d953242ede53ff0b5e50bdf69e29ea4 diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 76f9b242ca..a3820ee8e9 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -14,7 +14,7 @@ The coordinator registers, all through the composing fiber's effects: `session/c ## The redact waterfall -Every record passes the `telemetry/redact` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Every record passes the `telemetry/record` waterfall 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 @@ -39,4 +39,4 @@ 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/redact` 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. +- **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. diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index d930dc92e4..d607a4ab4d 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -14,7 +14,7 @@ ## 脱敏 waterfall -每条记录在投影与 `emit()` 之间都要经过 `telemetry/redact` waterfall(瀑布式事件),这是该 seam 的擦除扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式扣下这一条记录。脱敏只作用于导出副本;权威会话日志永不改写。 +每条记录在投影与 `emit()` 之间都要经过 `telemetry/record` waterfall(瀑布式事件),这是该 seam 的擦除扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式扣下这一条记录。脱敏只作用于导出副本;权威会话日志永不改写。 ## handoff 游标 @@ -39,4 +39,4 @@ ## 已知限制与延期工作 - **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 -- **不内置脱敏规则**:未挂载 `telemetry/redact` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。 +- **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index a5b552c765..76f3fb1f16 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -2,8 +2,8 @@ * 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/redact` waterfall (deployment-mounted rules; pass-through when - * none), and hands the result to the backend — synchronously, with every + * `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. @@ -178,14 +178,14 @@ export class TelemetryCoordinator { } /** - * Run the `telemetry/redact` waterfall over one record and hand the result + * 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/redact', record, () => record)) + this.backend.emit(this.ctx.waterfall('telemetry/record', record, () => record)) } /** Forward the turn-end boundary to the backend's optional flush hint. */ diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index b52f80fbbe..ca0f504647 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -22,8 +22,9 @@ declare module 'cordis' { interface Events { /** - * Redact one outbound record before it reaches the backend — the seam's - * scrubbing extension point. The seam ships NO rules of its own: the + * 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 @@ -37,7 +38,7 @@ declare module 'cordis' { * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall */ - 'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord + 'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord } } diff --git a/packages/telemetry/session-telemetry/tests/redact.spec.ts b/packages/telemetry/session-telemetry/tests/redact.spec.ts index c4b9d4d5bb..6e71d61627 100644 --- a/packages/telemetry/session-telemetry/tests/redact.spec.ts +++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts @@ -1,5 +1,5 @@ /** - * The `telemetry/redact` waterfall contract: pass-through when no listener is + * The `telemetry/record` waterfall contract: pass-through when no listener is * mounted, listener stacking and replacement, ops-record coverage, the * untouched canonical log, and the fail-closed containment of a throwing rule. */ @@ -35,7 +35,7 @@ async function setup() { return { ctx, backend, fiber } } -describe('telemetry/redact waterfall', () => { +describe('telemetry/record waterfall', () => { it('passes records through unchanged when no listener is mounted', async () => { const { ctx, backend } = await setup() const session = ctx.sessions.create(SessionId('w')) @@ -46,7 +46,7 @@ describe('telemetry/redact waterfall', () => { it('applies a mounted rule to every outbound record, ops records included', async () => { const { ctx, backend, fiber } = await setup() - ctx.on('telemetry/redact', (_record, next) => { + ctx.on('telemetry/record', (_record, next) => { const record = next() return { ...record, body: { scrubbed: true } } }) @@ -62,7 +62,7 @@ describe('telemetry/redact waterfall', () => { it('keeps the canonical log untouched by a mounted rule', async () => { const { ctx } = await setup() - ctx.on('telemetry/redact', (_record, next) => ({ ...next(), body: null })) + ctx.on('telemetry/record', (_record, next) => ({ ...next(), body: null })) const session = ctx.sessions.create(SessionId('log')) session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const logged = session.events[0]!.data as { content: { text: string }[] } @@ -72,13 +72,13 @@ describe('telemetry/redact waterfall', () => { it('stacks listeners outermost-first around next()', async () => { const { ctx, backend } = await setup() const order: string[] = [] - ctx.on('telemetry/redact', (_record, next) => { + ctx.on('telemetry/record', (_record, next) => { order.push('outer-before') const record = next() order.push('outer-after') return { ...record, attributes: { ...record.attributes, outer: 1 } } }) - ctx.on('telemetry/redact', (_record, next) => { + ctx.on('telemetry/record', (_record, next) => { order.push('inner') const record = next() return { ...record, attributes: { ...record.attributes, inner: 1 } } @@ -92,8 +92,8 @@ describe('telemetry/redact waterfall', () => { it('a listener that skips next() replaces everything beneath it', async () => { const { ctx, backend } = await setup() const inner = { called: false } - ctx.on('telemetry/redact', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord)) - ctx.on('telemetry/redact', (_record, next) => { + ctx.on('telemetry/record', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord)) + ctx.on('telemetry/record', (_record, next) => { inner.called = true return next() }) @@ -105,7 +105,7 @@ describe('telemetry/redact waterfall', () => { it('a throwing rule withholds the record fail-closed without disturbing the log', async () => { const { ctx, backend } = await setup() - ctx.on('telemetry/redact', () => { + ctx.on('telemetry/record', () => { throw new Error('rule exploded') }) const session = ctx.sessions.create(SessionId('closed'))