Merge branch 'master' into worktree-webheadless

This commit is contained in:
Tianyi Cui
2026-07-25 17:15:08 +08:00
committed by GitHub
135 changed files with 1047 additions and 843 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-18-shared-persistence-write-coordinator.md: ea9c4fb74f7c1bd68fb62efedd3e1657da96ea65 2026-06-18-shared-persistence-write-coordinator.md: 4632351a6f39c44c9ba8af58d508d4665b9e9279
2026-06-18-shared-persistence-write-coordinator.zh.md: 3b4dd7b762c2f39a908eabe23e5d734981b5767b 2026-06-18-shared-persistence-write-coordinator.zh.md: 40a7144038ac0db4ca6cac651c0a3cef5de4afa9

View File

@@ -23,7 +23,7 @@ The coordinator retires a session from `session/disposed`: it waits for the cont
Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage: Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage:
- `name` — backend label for the dispose-failure `AggregateError`. - `name` — backend label for the dispose-failure `AggregateError`.
- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. - `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication.
- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). - `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook).
- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). - `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`).
- `list()` — list all stored metadata. - `list()` — list all stored metadata.

View File

@@ -23,7 +23,7 @@ Status: implemented
五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界: 五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界:
- `name`——后端标签,用于 dispose 失败时的 `AggregateError` - `name`——后端标签,用于 dispose 失败时的 `AggregateError`
- `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀JSONL 的所有 cwd bucketSQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 - `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀JSONL 的所有项目目录SQLite 的 id 全局唯一)。恢复/加载、不修改状态的检查、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。
- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 - `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。
- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync先截断再追加SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers和 live-adoption仅截断`closers = []`)。 - `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync先截断再追加SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers和 live-adoption仅截断`closers = []`)。
- `list()`——列出所有已存储的元数据。 - `list()`——列出所有已存储的元数据。

View File

@@ -42,7 +42,7 @@ The agent loop keeps `RequestError` as that exact error object and passes `LlmFa
Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them. Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them.
The initial shared transient-code set is intentionally small: the adapters' existing `RATE_LIMIT` and `SERVER` mappings plus explicit `TIMEOUT` and `TRANSPORT` codes for the two missing remote-failure families. Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum. The shared transient-code set is intentionally small: adapter mappings for `RATE_LIMIT` and `SERVER`, explicit `TIMEOUT` and `TRANSPORT` codes for remote failures, and `EMPTY_RESPONSE` for a completed provider response with no content blocks. Both adapters classify the last case as an error finish; see [empty model responses are retryable](../bug-fix/2026-07-24-empty-model-response-is-retryable.md). Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum.
### Put retry policy on the existing failed-step seam ### Put retry policy on the existing failed-step seam
@@ -62,7 +62,7 @@ interface Config {
} }
``` ```
The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five transient codes above (`RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, and `EMPTY_RESPONSE`). The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets.
For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered.
@@ -124,7 +124,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason`
- Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff.
- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery.
- The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance.
- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus durable discarded-attempt markers in append-only ACP and stdio streams. Keyless snapshots cover scheduling, cancellation, success, and exhaustion. - The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted.
- Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it.
- Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts.

View File

@@ -12,9 +12,9 @@ Windows has atomic namespace operations, but Node does not expose a POSIX-equiva
The JSONL backend forks inside `materialize()` before any namespace mutation. Shared code computes the session directory, final log path, and encoded header plus initial event batch; POSIX and Windows then run separate publication protocols. The JSONL backend forks inside `materialize()` before any namespace mutation. Shared code computes the session directory, final log path, and encoded header plus initial event batch; POSIX and Windows then run separate publication protocols.
POSIX keeps the existing protocol: create the root and cwd bucket with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the bucket directory, then remove the redundant temp hard link. POSIX keeps the existing protocol: create the root, project directory, and session directory with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the session directory, then remove the redundant temp hard link.
Windows creates missing directories through a durable staging publish: create a random sibling directory, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules. Windows creates missing directories through a durable staging publish: create a random sibling directory under the constant `.dsh-mkdir-` prefix, independent of the target basename, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules.
## Alternatives considered ## Alternatives considered
@@ -28,6 +28,6 @@ Windows creates missing directories through a durable staging publish: create a
The backend keeps one external contract across platforms: first append either publishes a complete log at the final name or fails without overwriting an existing log. The platform split is an implementation detail; `SessionPersistence` APIs and the logical JSONL record format do not change. The later [Zstandard encoding decision](2026-07-19-zstandard-jsonl-session-logs.md) applies before either platform publishes the opaque bytes. The backend keeps one external contract across platforms: first append either publishes a complete log at the final name or fails without overwriting an existing log. The platform split is an implementation detail; `SessionPersistence` APIs and the logical JSONL record format do not change. The later [Zstandard encoding decision](2026-07-19-zstandard-jsonl-session-logs.md) applies before either platform publishes the opaque bytes.
Windows tests exercise the real Win32 publish path on native Windows. Power-loss behavior remains an API-contract property rather than something unit tests can prove; the testable invariants are that directory fsync is not called on Windows materialization, final-path collisions fail, temp logs are fsync'd before publication, and the resulting log loads normally. Windows tests exercise the real Win32 publish path on native Windows. Power-loss behavior remains an API-contract property rather than something unit tests can prove; the testable invariants are that directory fsync is not called on Windows materialization, final-path collisions fail, maximum-length target components remain materializable, temp logs are fsync'd before publication, and the resulting log loads normally.
Append and repair still use ordinary file-handle fsyncs on both platforms. A failed append closes its append-only handle, reopens the log read/write, truncates it to the pre-append size, and fsyncs the rollback because Windows rejects `ftruncate` on append-only handles. Append and repair still use ordinary file-handle fsyncs on both platforms. A failed append closes its append-only handle, reopens the log read/write, truncates it to the pre-append size, and fsyncs the rollback because Windows rejects `ftruncate` on append-only handles.

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-24-project-session-directories.md: 0aa3f513d5a1bb3e44cf33a0ae1eb791ee3a46c2
2026-07-24-project-session-directories.zh.md: 3d8d33fa9fddad010ab319ac4e1f873b69b4e1dd

View File

@@ -0,0 +1,52 @@
# Agent Note: Project-grouped session directories
Status: implemented
English | [中文](2026-07-24-project-session-directories.zh.md)
## Problem
A persistence root may be local to one project, shared by several projects, temporary, or centralized. The hashed cwd buckets kept all deployments functional but made a shared root difficult to navigate because a developer could not recognize a project from its directory name.
Each JSONL session also occupied one file directly inside the project bucket. That shape had no ownership directory for additional session artifacts such as metadata, attachments, spill files, or coordination state.
## Decision
The JSONL backend stores sessions under a readable project key and gives every session its own directory:
```text
<configured-root>/
--<normalized-cwd>--/
<encoded-session-id>/
session.jsonl.zstd
```
Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable name is bounded to keep the component within filesystem limits.
The project key intentionally has no hash suffix. This follows the common human-readable convention used by coding agents and keeps the normalized project path as the complete directory name. The normalization is lossy: paths such as `/a/b-c` and `/a-b/c`, or long paths with the same retained prefix, share one project directory. Their distinct session ids still select separate session directories; reuse of the same session id remains a storage collision and is rejected.
Case-insensitive filesystems can also make differently cased project keys refer to one physical directory. Identity validation accepts such an alternate spelling only when filesystem canonicalization resolves the discovered and expected paths to the same transcript. A different canonical path remains corruption, so case aliases do not weaken the same-id collision check on case-sensitive stores.
The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure.
The encoded session id names an ownership directory rather than the transcript itself. `SessionPersistence.locate()` continues to return the fixed transcript path, preserving hook `transcript_path` and `DSH_SESSION_JSONL` semantics. Discovery ignores other entries inside the session directory so the backend can add session-owned artifacts without another layout change.
Lazy materialization remains tied to the transcript: `create()` performs no filesystem I/O, and the first append creates the project/session directories before collision-safe transcript publication. Empty directories are not listed as sessions. The backend rejects flat `<project>/<id>.jsonl*` artifacts with an explicit layout error; the pre-release format provides no automatic data migration.
## Alternatives considered
**Keep opaque cwd hashes.** This preserved short names but defeated the requested navigation by project path when several projects share a persistence root.
**Put session files directly in each project directory.** This matched Claude Code and pi's basic file organization but left no session-level ownership boundary for future artifacts.
**Add a collision-resistant hash suffix.** This distinguishes paths whose normalized forms collide, but makes the directory name more than the normalized project path. The chosen convention accepts lossy project grouping in exchange for the simpler, recognizable name.
**Mandate a centralized root.** Rejected because storage placement belongs to deployment configuration. Project grouping is useful when roots are shared and harmless when they are not.
**Load both flat and directory layouts.** Rejected under the pre-release no-compatibility stance. One accepted layout keeps identity checks and discovery deterministic.
## Consequences
Shared stores can be navigated by recognizable project names, while local and custom roots keep their existing configuration freedom. Every session has a directory available for future backend-owned artifacts, and existing transcript consumers still receive a file path.
Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix. Moving a project usually selects a different directory, but distinct cwd strings that normalize to the same name share one project directory by design.

View File

@@ -0,0 +1,52 @@
# Agent Note: 按项目分组的会话目录
Status: implemented
[English](2026-07-24-project-session-directories.md) | 中文
## 问题
持久化根目录可以只供一个项目使用,也可以由多个项目共享,还可以是临时目录或集中式目录。对 cwd 进行哈希得到的分桶目录能适用于所有这些部署方式,但开发者无法从目录名辨认项目,因此共享根目录难以浏览。
每个 JSONL 会话也直接以单个文件的形式放在项目分桶目录中。这种布局没有为元数据、附件、溢写文件或协调状态等其他会话产物提供归属目录。
## 决策
JSONL 后端按可读的项目键存储会话,并为每个会话提供独立目录:
```text
<configured-root>/
--<normalized-cwd>--/
<encoded-session-id>/
session.jsonl.zstd
```
原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读名称则限制长度,以确保目录项不超过文件系统限制。
项目键有意不带哈希后缀。这遵循 coding agent编码智能体常用的易读约定使规范化后的项目路径本身就是完整的目录名。规范化过程有损`/a/b-c``/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。
在不区分大小写的文件系统上,大小写不同的项目键也可能指向同一个物理目录。只有当文件系统路径规范化将发现路径和预期路径解析为同一个 transcript文本记录身份验证才接受这种拼写变体。规范化后的路径如果不同仍视为存储损坏因此大小写别名不会让区分大小写的存储放宽同一 id 的冲突检查。
根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。
编码后的会话 id 用于命名归属目录,而不是 transcript 文件本身。`SessionPersistence.locate()` 仍返回固定的 transcript 路径,从而保持钩子 `transcript_path``DSH_SESSION_JSONL` 的语义不变。发现过程会忽略会话目录中的其他条目,因此后端以后添加会话自有产物时无需再次改变布局。
延迟物化仍以 transcript 为界:`create()` 不执行文件系统 I/O首次追加会先创建项目目录和会话目录再以无冲突方式发布 transcript。空目录不会被列为会话。后端会显式报告布局错误并拒绝扁平的 `<project>/<id>.jsonl*` 产物;预发布格式不提供自动数据迁移。
## 考虑过的替代方案
**保留不透明的 cwd 哈希。** 这可以保持目录名简短,但当多个项目共享一个持久化根目录时,无法满足按项目路径浏览的需求。
**把会话文件直接放入各项目目录。** 这与 Claude Code 和 pi 的基本文件组织一致,但没有为未来产物提供会话级归属边界。
**添加防冲突的哈希后缀。** 这种方式能区分规范化形式相同的路径,但会使目录名不再只是规范化后的项目路径。所选约定接受有损的项目分组,以换取更简单、易于辨认的名称。
**强制使用集中式根目录。** 不予采纳,因为存储位置属于部署配置。项目分组在根目录共享时有用,在不共享时也没有负面影响。
**同时加载扁平布局和目录布局。** 按照预发布阶段不提供兼容性的原则,不予采纳。只接受一种布局,可以让身份检查和发现过程保持确定性。
## 后果
共享存储可以通过易于辨认的项目名进行浏览,本地根目录和自定义根目录则继续保有现有的配置自由。每个会话都有一个可供后端未来存放自有产物的目录,而现有 transcript 消费方仍会收到文件路径。
项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀。移动项目通常会选择不同的目录,但按设计,不同的 cwd 字符串如果规范化成相同名称,就会共用同一个项目目录。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-20-jsonl-storage-identity.md: 1ada16791f411a54fbcf9271c7d7963223bbe683 2026-07-20-jsonl-storage-identity.md: 1079eb700c819951dbb81e99376c0b71e3e84617
2026-07-20-jsonl-storage-identity.zh.md: 8027c51dbf6c7d01463b7851d859a40890bf03e1 2026-07-20-jsonl-storage-identity.zh.md: d7ba5c646a7adaaa0ebd60fac7b9c2f030361ff9

View File

@@ -6,11 +6,11 @@ English | [中文](2026-07-20-jsonl-storage-identity.zh.md)
## Problem ## Problem
JSONL lookup selects a physical log from the requested session id across cwd buckets, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The bucket scan also needs a defined result when the same encoded id exists in more than one bucket. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id. JSONL lookup selects a physical log from the requested session id across project directories, while the parsed `SessionHeader` supplies the metadata used by later repair and append operations. Without binding those two facts, a log selected for session A can declare session B's id or cwd and redirect a repair or later append to B's path. The project scan also needs a defined result when the same encoded id exists in more than one project directory. SQLite does not share this ambiguity because its primary-key query binds metadata and events to the requested id.
## Decision ## Decision
`loadStored(id)` is the coordinator's single stored-prefix lookup. The JSONL backend scans every cwd bucket, requires at most one matching encoded filename, parses that file, then validates both `header.id === id` and `selectedPath === logPath(root, header.cwd, header.id)` before returning metadata. `list()` applies the same path validation and rejects duplicate ids across buckets. `loadStored(id)` is the coordinator's single stored-prefix lookup. The JSONL backend scans every project directory, requires at most one matching encoded session directory with a transcript, parses that file, then validates `header.id === id` and that the selected path either equals `logPath(root, header.cwd, header.id)` or filesystem canonicalization resolves both spellings to the same transcript. `list()` applies the same path validation and rejects duplicate ids across project directories.
The coordinator independently asserts the returned id and compares the stored cwd with a live session's cwd before repair, state publication, or suffix persistence. It keeps a detached copy of validated metadata; JSONL append and repair derive their path from that copy. The `PersistenceBackend<TornMarker>` interface therefore needs neither a scope-specific live lookup nor a storage-locator type. The coordinator independently asserts the returned id and compares the stored cwd with a live session's cwd before repair, state publication, or suffix persistence. It keeps a detached copy of validated metadata; JSONL append and repair derive their path from that copy. The `PersistenceBackend<TornMarker>` interface therefore needs neither a scope-specific live lookup nor a storage-locator type.
@@ -18,7 +18,7 @@ An existing configured JSONL root must be a readable directory when the plugin l
## Alternatives considered ## Alternatives considered
**Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without changing the project-grouped cwd layout or its consumers. **Flatten storage by session id.** A flat namespace makes duplicate publication collide on one path, but path validation and duplicate rejection close the identity defect without making the check depend on a flat global namespace.
**Carry an opaque storage locator through the coordinator.** A locator binds JSONL mutations directly to a selected path, but JSONL can reproduce that path from metadata it has already validated. Adding another generic and argument to SQLite, test backends, append, and repair makes every implementation carry a concept only the file backend needs. **Carry an opaque storage locator through the coordinator.** A locator binds JSONL mutations directly to a selected path, but JSONL can reproduce that path from metadata it has already validated. Adding another generic and argument to SQLite, test backends, append, and repair makes every implementation carry a concept only the file backend needs.
@@ -26,4 +26,4 @@ An existing configured JSONL root must be a readable directory when the plugin l
## Consequences ## Consequences
Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. The cwd-bucket format stays unchanged and needs no migration. Lookup remains proportional to the number of buckets, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, cwd collision handling, and load-time root validation. Mismatched, misplaced, and duplicate JSONL logs fail before repair or coordinator state mutation. Lookup remains proportional to the number of project directories, and one-live-writer ownership remains an explicit limitation. Coordinator and JSONL tests pin rejection before repair, unchanged bytes for both affected logs, path validation during listing, duplicate-id rejection, normalized-project collisions and case aliases, and load-time root validation.

View File

@@ -6,11 +6,11 @@ Status: implemented
## 问题 ## 问题
JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个分桶目录中时,分桶扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。 JSONL 查找会根据请求的会话 id 在各个项目目录中选出物理日志,而解析得到的 `SessionHeader` 会提供后续修复和追加操作使用的元数据。如果这两个事实没有绑定,为会话 A 选中的日志就能声明会话 B 的 id 或 cwd并将修复或后续追加重定向到 B 的路径。当同一个编码后 id 出现在多个项目目录中时,项目扫描也必须给出确定的结果。SQLite 不存在这种歧义,因为主键查询会将元数据和事件绑定到请求的 id。
## 决策 ## 决策
`loadStored(id)` 是协调器唯一的已存前缀查找操作。JSONL 后端扫描所有 cwd 分桶目录,要求匹配编码文件名的日志至多有一个,解析该文件,然后在返回元数据前同时验证 `header.id === id``selectedPath === logPath(root, header.cwd, header.id)``list()` 执行相同的路径验证,并拒绝跨分桶目录重复的 id。 `loadStored(id)` 是协调器唯一的已存前缀查找操作。JSONL 后端扫描所有项目目录,要求名称与该 id 的编码值匹配且其中包含 transcript文本记录的会话目录至多有一个解析其中的 transcript然后验证 `header.id === id`,并验证选定路径要么等于 `logPath(root, header.cwd, header.id)`,要么经文件系统路径规范化后,两种写法解析为同一份 transcript`list()` 执行相同的路径验证,并拒绝跨项目目录重复的 id。
协调器会独立断言返回的 id并在修复、发布状态或持久化后缀之前比较已存 cwd 和活动会话的 cwd。协调器保留一份已验证元数据的独立副本JSONL 的追加和修复操作根据该副本派生路径。因此,`PersistenceBackend<TornMarker>` 接口既不需要限定范围的活动会话查找,也不需要存储定位器类型。 协调器会独立断言返回的 id并在修复、发布状态或持久化后缀之前比较已存 cwd 和活动会话的 cwd。协调器保留一份已验证元数据的独立副本JSONL 的追加和修复操作根据该副本派生路径。因此,`PersistenceBackend<TornMarker>` 接口既不需要限定范围的活动会话查找,也不需要存储定位器类型。
@@ -18,7 +18,7 @@ JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物
## 考虑过的替代方案 ## 考虑过的替代方案
**按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需改变按项目分组的 cwd 布局及其消费方,也能消除身份缺陷。 **按会话 id 扁平化存储。** 扁平命名空间会让重复发布在同一路径上冲突,但路径验证和重复项拒绝无需让检查依赖扁平的全局命名空间,也能消除身份缺陷。
**通过协调器传递不透明存储定位器。** 定位器可以将 JSONL 变更直接绑定到选定路径,但 JSONL 可以根据已经验证的元数据重新得到该路径。为 SQLite、测试后端、追加和修复操作增加一个泛型和参数会让每个实现都承担只有文件后端需要的概念。 **通过协调器传递不透明存储定位器。** 定位器可以将 JSONL 变更直接绑定到选定路径,但 JSONL 可以根据已经验证的元数据重新得到该路径。为 SQLite、测试后端、追加和修复操作增加一个泛型和参数会让每个实现都承担只有文件后端需要的概念。
@@ -26,4 +26,4 @@ JSONL 查找会根据请求的会话 id 在各个 cwd 分桶目录中选出物
## 后果 ## 后果
JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。cwd 分桶格式保持不变,无需迁移。查找开销仍与分桶目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝、cwd 冲突处理以及加载时的根目录验证。 JSONL 日志的身份不匹配、位置错误和重复会在修复或协调器状态变更前失败。查找开销仍与项目目录数量成正比,单一活动写入方的所有权仍是明确限制。协调器和 JSONL 测试固定了修复前拒绝、两个受影响日志的字节均保持不变、列出时的路径验证、重复 id 拒绝、项目路径规范化冲突与大小写别名,以及加载时的根目录验证。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-24-empty-model-response-is-retryable.md: f4a6373178efd5ca1ba5882fb2aaf97dffb2526b
2026-07-24-empty-model-response-is-retryable.zh.md: 4c3afe44140c029d274f34ade97803b958c6d669

View File

@@ -0,0 +1,36 @@
# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures
Status: implemented
English | [中文](2026-07-24-empty-model-response-is-retryable.zh.md)
## Problem
Providers occasionally return a degenerate completion: a well-formed stream that carries a terminal `stop` finish and zero content blocks — no text, no reasoning, no tool calls. If an adapter maps this shape to a successful `{kind: 'stop'}` finish, the loop logs an empty `assistant/message` and ends the turn as `completed`. Retry never runs, no failure reaches the caller, and a driver such as goal-session consumes a round without progress.
## Decision
An adapter classifies a completed empty response as a provider-boundary failure, and retry policy treats it as transient:
- `dsh-llm` exports the canonical code `EMPTY_RESPONSE_CODE` (`'EMPTY_RESPONSE'`) beside `CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE`.
- `dsh-llm-pi-ai` (`mapStopReason`): a terminal `stop` whose assistant message has no content blocks becomes a `finish {kind: 'error'}` with that code. Context-overflow detection still wins where it applies (it is checked first and is the more actionable classification).
- `dsh-llm-deepseek` (`translate`): at `[DONE]`, a `stop` (or absent) finish with no opened blocks becomes the same error finish. Reasoning-only streams count as content and stay successful.
- `dsh-llm-retry` adds `EMPTY_RESPONSE` to `DEFAULT_RETRYABLE_CODES`: the attempt produced nothing durable, so repeating it is safe; deployments can still remove it via `retryableCodes`.
Detection is scoped to `stop` finishes only. `max-tokens` with empty content keeps its existing meaning (pi-ai already normalizes the zero-output overflow case), `tool-calls` cannot be block-empty in practice, and error/aborted finishes already fail.
The classification uses the existing loop machinery — `finishError``agent/request-error``dsh-llm-retry` — and keeps `agent-loop` provider-neutral. Exhausting the retry budget ends the turn with an explicit `EMPTY_RESPONSE` failure instead of an empty success.
## Alternatives considered
**Detect in the loop or `BlockAssembler`.** One shared implementation, but it moves provider-response judgment into the loop, against "plugins, not loop changes", and the assembler is a pure assembly algorithm. The adapter is where wire facts become harness classification, with the overflow reclassification as exact precedent.
**A stream-transform plugin on the `llm/stream` waterfall.** Provider-neutral and one implementation, but it adds a package plus wiring for what is a boundary fact each adapter can state in a few lines, and default-on behavior would still require touching every bundle.
**Treat whitespace-only or reasoning-only responses as empty too.** Rejected as overreach: those carry model-produced content, and misclassifying a legitimate (if useless) response as a transport-class failure risks retry loops on models that intentionally stop after reasoning. The scope is exactly "zero content blocks".
## Consequences
- A transiently misbehaving provider consumes a bounded retry instead of a turn with no output; a persistently empty model surfaces an actionable `EMPTY_RESPONSE` turn failure.
- A model that genuinely intends to say nothing (rare, but possible after a tool result) is retried and, if consistently empty, fails the turn. This trade was accepted deliberately: an empty assistant message is indistinguishable from the provider defect and has no value to the user.
- The `empty-response-retry` ACP snapshot (an authored keyless scenario with a deterministic 1 ms zero-jitter retry overlay, `examples/acp-agent/retry.cordis.yml`) pins the product-visible behavior: a durable `llm/retry` event, no ACP output for the discarded attempt, the recovered reply, and a clean completed turn.

View File

@@ -0,0 +1,36 @@
# Agent Note: Empty model completions are retryable EMPTY_RESPONSE failures
Status: implemented
[English](2026-07-24-empty-model-response-is-retryable.md) | 中文
## Problem
提供方偶尔会返回一种退化的 completion流本身格式完好携带一个终止性的 `stop` 结束,却没有任何内容块——没有文本、没有 reasoning推理、没有工具调用。如果适配器把这种形态映射为成功的 `{kind: 'stop'}` 结束,主循环就会记录一条空的 `assistant/message`,并把该轮次以 `completed` 结束。系统不会重试,失败也不会向调用方暴露,而像 goal-session 这样的驱动方会消耗一个轮次,却没有取得任何进展。
## Decision
由适配器把「已完成但为空」的响应归类为一次提供方边界失败,重试策略则将其视为瞬时性问题:
- `dsh-llm``CONTEXT_WINDOW_EXCEEDED_CODE`/`QUOTA_EXCEEDED_CODE` 之外,导出规范代码 `EMPTY_RESPONSE_CODE``'EMPTY_RESPONSE'`)。
- `dsh-llm-pi-ai``mapStopReason`):当终止性 `stop` 所对应的 assistant 消息没有内容块时,它会变成一个携带该代码的 `finish {kind: 'error'}`。上下文溢出检测在其适用场景中仍然优先(它先被检查,也是更具可操作性的归类)。
- `dsh-llm-deepseek``translate`):在 `[DONE]` 处,若 `stop`(或缺失)结束且没有打开过任何块,则同样变成该错误结束。仅含 reasoning 的流算作有内容,仍视为成功。
- `dsh-llm-retry``EMPTY_RESPONSE` 加入 `DEFAULT_RETRYABLE_CODES`:这次尝试没有产生任何持久内容,因此重复它是安全的;部署方仍可通过 `retryableCodes` 将其移除。
检测仅限于 `stop` 结束。内容为空的 `max-tokens` 保持其既有含义pi-ai 已经把零输出的溢出场景归一化处理),`tool-calls` 在实践中不可能是空块,而 erroraborted 结束本身已经算失败。
这套归类使用既有的主循环机制——`finishError``agent/request-error``dsh-llm-retry`——并让 `agent-loop` 保持提供方无关。重试预算耗尽时,该轮次会以显式的 `EMPTY_RESPONSE` 失败结束,而不是在没有内容的情况下成功结束。
## Alternatives considered
**在主循环或 `BlockAssembler` 中检测。** 只需一份共享实现,但这会把对提供方响应的判断挪进主循环,违背「插件优先,而非改动主循环」,且 assembler 是纯粹的组装算法。适配器才是把协议层面的事实转化为 harness 归类的地方,而溢出重归类正是精确的先例。
**在 `llm/stream` waterfall瀑布式事件上做一个流转换插件。** 这种做法提供方无关且只需一份实现,但它为「每个适配器几行就能声明的边界事实」额外增加了一个包和相应接线,而且默认开启的行为仍需改动每一个 bundle。
**把仅含空白或仅含 reasoning 的响应也当作空响应。** 作为过度设计予以否决:这类响应携带了模型产生的内容,把一个合法(哪怕无用)的响应误判为传输类失败,会在那些故意在 reasoning 之后停止的模型上引发重试循环。其范围严格限定为「零内容块」。
## Consequences
- 一个偶发异常的提供方会消耗一次有界重试,而不是一个没有输出的轮次;一个持续返回空内容的模型则会暴露为用户可据以行动的 `EMPTY_RESPONSE` 轮次失败。
- 一个确实打算什么都不说的模型(罕见,但在一次工具结果之后有可能出现)会被重试,若始终为空,则该轮次失败。这个取舍是经过审慎权衡后接受的:一条空的 assistant 消息与提供方缺陷无法区分,且对用户毫无价值。
- `empty-response-retry` ACP 快照(一个人工编写的无密钥场景,配有确定性的 1 ms 零抖动重试 overlay`examples/acp-agent/retry.cordis.yml`)钉住了产品可见的行为:持久的 `llm/retry` 事件、被丢弃的尝试不产生任何 ACP 输出、恢复后的回复,以及一次干净的已完成轮次。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-06-22-subagent-snapshot-replay.md: 6e5e94308ed145b83160146fd9e9ef023f2dde5d 2026-06-22-subagent-snapshot-replay.md: 8cd7bc86e07af9ed274c18574b575b9070854e88
2026-06-22-subagent-snapshot-replay.zh.md: 82bb7d0735c7dbf918941d00ee4c59498cc59085 2026-06-22-subagent-snapshot-replay.zh.md: eae78129405fedd03c2c579845c07c6e5694cc30

View File

@@ -11,7 +11,7 @@ The snapshot tier (`pnpm run test:snapshot`) boots the real `acp-agent` subproce
It was built for ONE session per process, and that assumption is wired into two places: It was built for ONE session per process, and that assumption is wired into two places:
- **`dsh-llm-replay` keyed nothing.** It served the Nth `llm/stream` call the Nth recorded entry from a single global cursor. With a parent agent AND an in-process subagent both streaming on one context, the calls interleave and the single cursor hands the child the parent's script (and vice versa). - **`dsh-llm-replay` keyed nothing.** It served the Nth `llm/stream` call the Nth recorded entry from a single global cursor. With a parent agent AND an in-process subagent both streaming on one context, the calls interleave and the single cursor hands the child the parent's script (and vice versa).
- **The harness harvested one log.** `findSessionLog` walked the sessions root and returned the FIRST `.jsonl` it found. A subagent runs as a second `Session` with its own log in the same cwd bucket, so the child's transcript was silently dropped. - **The harness harvested one log.** `findSessionLog` walked the sessions root and returned the FIRST `.jsonl` it found. A subagent runs as a second `Session` with its own log, so the child's transcript was silently dropped.
This was the `TODO(subagent-snapshots)` deferral recorded in the [subagent seam Agent Note](../feature/2026-06-21-subagent-capability-seam.md): the in-process backends (PR2) shipped with unit + e2e coverage, but the full-transcript snapshot tier could not express a nested-agent shape until this infrastructure landed. This Agent Note is that stacked follow-up. This was the `TODO(subagent-snapshots)` deferral recorded in the [subagent seam Agent Note](../feature/2026-06-21-subagent-capability-seam.md): the in-process backends (PR2) shipped with unit + e2e coverage, but the full-transcript snapshot tier could not express a nested-agent shape until this infrastructure landed. This Agent Note is that stacked follow-up.
@@ -39,7 +39,7 @@ The alternative considered and rejected was a **call-ordered merge of the parent
### 3. The harness harvests every log, primary-first ### 3. The harness harvests every log, primary-first
`harvestSessionLogs` collects every `.jsonl` across every cwd bucket under the sessions root (the JSONL backend puts a parent and its same-cwd child in the same bucket), parses each header, and orders them primary-first: the top-level session (no `parentSession`) leads, then each child by ascending `createdAt`. `RunResult.sessionLogs` is the plural result; the spec writes each back to its fixture on record (`session.jsonl` + `session.<n>.jsonl`) and diffs each harvested log against its fixture on replay. The normalizer already accepted plural session ids and collapses any stray UUID, so no normalizer change was needed. `harvestSessionLogs` recursively collects every fixed `session.jsonl` transcript under the sessions root (the JSONL backend gives each parent and child its own project/session directory), parses each header, and orders them primary-first: the top-level session (no `parentSession`) leads, then each child by ascending `createdAt`. `RunResult.sessionLogs` is the plural result; the spec writes each back to its fixture on record (`session.jsonl` + `session.<n>.jsonl`) and diffs each harvested log against its fixture on replay. The normalizer already accepted plural session ids and collapses any stray UUID, so no normalizer change was needed.
### 4. Scenarios ### 4. Scenarios

View File

@@ -11,7 +11,7 @@ Status: implemented
该层最初为每个进程只有一个会话而构建,这一假设硬编码在两处: 该层最初为每个进程只有一个会话而构建,这一假设硬编码在两处:
- **`dsh-llm-replay` 没有做任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent智能体和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本发给父 agent反之亦然 - **`dsh-llm-replay` 没有做任何键控。** 它用一个全局游标,将第 N 次 `llm/stream` 调用对应到单一录制序列的第 N 条。当父 agent智能体和一个进程内 subagent 在同一个上下文上同时流式输出时,调用交错,单一游标会把子 agent 的脚本发给父 agent反之亦然
- **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行,在同一个 cwd bucket 下有自己的日志,因此子 agent 的 transcript文本记录被静默丢弃。 - **harness 只收集一份日志。** `findSessionLog` 遍历 sessions 根目录,返回找到的第一个 `.jsonl`。subagent 作为第二个 `Session` 运行并拥有自己的日志,因此子 agent 的 transcript文本记录被静默丢弃。
这就是 [subagent seam Agent Noteagent 决策记录)](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作进程内后端PR2落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。本 Agent Note 就是该堆叠式后续工作。 这就是 [subagent seam Agent Noteagent 决策记录)](../feature/2026-06-21-subagent-capability-seam.md)中通过 `TODO(subagent-snapshots)` 推迟的工作进程内后端PR2落地时已有单元 + e2e 覆盖,但在这套基础设施落地前,完整 transcript 快照层无法表达嵌套 agent 形状。本 Agent Note 就是该堆叠式后续工作。
@@ -39,7 +39,7 @@ Status: implemented
### 3. harness 收集所有日志,主会话优先 ### 3. harness 收集所有日志,主会话优先
`harvestSessionLogs` 收集 sessions 根目录下每个 cwd bucket 中的所有 `.jsonl`JSONL 后端将父会话与同 cwd 的子会话放在同一个 bucket),解析各自的 header并按主会话优先排序顶层会话`parentSession`)在前,各子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果spec 在录制时将每份日志写回对应 fixture`session.jsonl` + `session.<n>.jsonl`),在回放时将每份收集到的日志与其 fixture 做 diff。归一化器已支持复数会话 id 并会折叠任何游离 UUID因此无需修改归一化器。 `harvestSessionLogs` 递归收集 sessions 根目录下所有固定命名为 `session.jsonl` 的 transcriptJSONL 后端为每个父会话和子会话分别提供独立的项目/会话目录),解析各自的 header并按主会话优先排序顶层会话`parentSession`)在前,各子会话按 `createdAt` 升序排列。`RunResult.sessionLogs` 是复数结果spec 在录制时将每份日志写回对应 fixture`session.jsonl` + `session.<n>.jsonl`),在回放时将每份收集到的日志与其 fixture 做 diff。归一化器已支持复数会话 id 并会折叠任何游离 UUID因此无需修改归一化器。
### 4. 场景 ### 4. 场景

View File

@@ -7,6 +7,8 @@ description: Use when writing, reviewing, restoring, trimming, or auditing prose
Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates. It is guidance, not a script. Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates. It is guidance, not a script.
Comments describe non-obvious contracts or rationale that code cannot express; they do not restate what code already implies.
## Inputs and exclusions ## Inputs and exclusions
Require an explicit `scope`. If it is missing, report the required input and stop; do not infer a repository-wide scope or begin an interview. Require an explicit `scope`. If it is missing, report the required input and stop; do not infer a repository-wide scope or begin an interview.

View File

@@ -1,10 +1,6 @@
/** /**
* Browser stand-in for `node:module`, mapped by the vite alias in * Browser stand-in for `node:module`. `createRequire` is unreachable in the
* vite.config.ts (design §2.4). The vendored Loader's internal.ts imports * configured loader path and fails loud if that assumption changes.
* `createRequire` at module scope but only calls it inside
* `ModuleLoader.fromInternal()`, whose version probe is compiled to the
* `"0.0.0"` define in the browser build — so this throw is a fail-loud
* tripwire for any path that would genuinely need Node's module machinery.
*/ */
/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */ /** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */

View File

@@ -333,10 +333,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.` const prompt = `Please answer this request carefully: explain event sourcing in two sentences, ending with exactly ${ROUND_DONE_MARKER}.`
await input.fill(prompt) await input.fill(prompt)
await input.press('Enter') await input.press('Enter')
// startSession chain: session mounts, composer moves to the bottom. // The first send must keep the session tree mounted; a near-empty body
// Regression pin (P0, 585671106): this send used to white-screen the tree // reveals a duplicate runtime bundle with incompatible scope tags.
// (scope tag lost to a duplicate inlined runtime instance) — body going
// near-empty here means that class of bug is back.
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 }) await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
expect(pageErrors).toEqual([]) expect(pageErrors).toEqual([])
await page.waitForFunction( await page.waitForFunction(

View File

@@ -753,12 +753,12 @@ Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src
Requires: `tools` Requires: `tools`
```ts config-catalog ```ts config-catalog
/** Discriminated union of all supported MCP transport configurations. */ /** Configuration for one stdio or Streamable HTTP MCP server. */
export type Config = StdioConfig | StreamableHttpConfig export type Config = StdioConfig | StreamableHttpConfig
/** Config for connecting to an MCP server via a spawned child process over stdio. */ /** Config for connecting to an MCP server via a spawned child process over stdio. */
export interface StdioConfig { export interface StdioConfig {
/** Transport type: spawn a child process and communicate over stdio. */ /** Selects child-process stdio transport. */
transport: 'stdio' transport: 'stdio'
/** /**
* Stable local namespace for this server's model-facing tool names * Stable local namespace for this server's model-facing tool names
@@ -766,21 +766,21 @@ export interface StdioConfig {
* unique across live mcp-client instances. * unique across live mcp-client instances.
*/ */
serverName: string serverName: string
/** Executable to spawn. */ /** Executable used to start the server. */
command: string command: string
/** Arguments passed to the command. */ /** Arguments passed directly, without shell interpolation. */
args: string[] args: string[]
/** Extra env vars merged on top of scrubbed ambient env. */ /** Extra env vars merged on top of scrubbed ambient env. */
env: Record<string, string> env: Record<string, string>
/** Working directory for the child process. */ /** Working directory for the child process. */
cwd: string cwd: string
/** Timeout per callTool invocation (ms). */ /** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number toolCallTimeoutMs: number
} }
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */ /** Config for connecting to an MCP server over Streamable HTTP (SSE). */
export interface StreamableHttpConfig { export interface StreamableHttpConfig {
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */ /** Selects Streamable HTTP transport. */
transport: 'streamable-http' transport: 'streamable-http'
/** /**
* Stable local namespace for this server's model-facing tool names * Stable local namespace for this server's model-facing tool names
@@ -788,11 +788,11 @@ export interface StreamableHttpConfig {
* unique across live mcp-client instances. * unique across live mcp-client instances.
*/ */
serverName: string serverName: string
/** MCP server URL. */ /** MCP endpoint URL. */
url: string url: string
/** Extra headers (e.g. auth tokens). */ /** Additional headers attached to MCP requests. */
headers: Record<string, string> headers: Record<string, string>
/** Timeout per callTool invocation (ms). */ /** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number toolCallTimeoutMs: number
} }
``` ```
@@ -978,9 +978,9 @@ export interface Config {
/** /**
* Root directory for all session files. Required (no default): a default of * Root directory for all session files. Required (no default): a default of
* `process.cwd()` would scatter session files as the process's cwd changes * `process.cwd()` would scatter session files as the process's cwd changes
* (bash calls, subprocesses). Sessions group under per-cwd subdirectories. An * (bash calls, subprocesses). Sessions group under human-readable project
* existing root must be a readable directory; an absent root is created on * directories, then per-session directories. An existing root must be a
* first materialization. * readable directory; an absent root is created on first materialization.
*/ */
root: string root: string
/** /**

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
llm-streaming.md: cb99c935aea2dc9cc769e3056fdb98a2e5c9eacb llm-streaming.md: fb97e74a9ec01e62ba940295112fb157cc73bd1d
llm-streaming.zh.md: 740fa1f796088e63d0195cfbecf975adb236381c llm-streaming.zh.md: fda59a64aeef1c037372d69dbc15ee0de48de222

View File

@@ -63,6 +63,7 @@ Every adapter MUST obey these, and every consumer may rely on them:
- **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered step; direct `ctx.llm.stream()` callers remain single-attempt. - **One adapter call is one provider attempt.** Adapters disable library retries. Agent-level recovery opens another durable numbered step; direct `ctx.llm.stream()` callers remain single-attempt.
- **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`. - **Provider stalls are bounded at the transport.** Both shipping remote adapters expose positive finite `streamIdleTimeoutMs` with a five-minute default. The watchdog arms only while iterator `next()` is outstanding, uses one stable signal for the whole request, maps its own expiry to `TIMEOUT`, and keeps an earlier caller abort as `ABORTED`.
- **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text.
- **An empty completion is a retryable error, not a silent success.** Both adapters map a terminal `stop` finish that carried no content blocks to `finish {kind:'error'}` with the canonical `EMPTY_RESPONSE` code, and `dsh-llm-retry` retries it by default; see [empty model responses are retryable](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md).
- **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter).
- **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. - **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state.

View File

@@ -63,6 +63,7 @@ interface LlmFailure {
- **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。 - **一次适配器调用就是一次提供方尝试。** 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的步骤;直接调用 `ctx.llm.stream()` 的调用方仍然只尝试一次。
- **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。 - **提供方停顿在传输层受到时限约束。** 两个已交付的远程适配器都暴露正数且有限的 `streamIdleTimeoutMs`默认五分钟。watchdog 只在 iterator `next()` 尚未完成时启动,整个请求使用同一个稳定 signal把自身到期映射为 `TIMEOUT`,并把更早发生的调用方中止保留为 `ABORTED`。
- **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。
- **空 completion 是可重试错误,而不是静默的成功结果。** 两个适配器都把没有携带任何内容块的终止性 `stop` 结束映射为携带规范 `EMPTY_RESPONSE` code 的 `finish {kind:'error'}``dsh-llm-retry` 默认会重试它;详见[空模型响应可重试](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md)。
- **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线并通过协议级测试加以证明mock 服务器断言收到的 header或对基于库的适配器使用库的 header 钩子)。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线并通过协议级测试加以证明mock 服务器断言收到的 header或对基于库的适配器使用库的 header 钩子)。
- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance不会收到私有状态。 - **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。除非 `agent/step-result` listener 改写了内容,否则循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmService` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance不会收到私有状态。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
persistence.md: dc497fd85f44660c0a981579351b5cfbe0040a4d persistence.md: b03cc07d2e514b3900d4035ea386f31c761470a7
persistence.zh.md: 5236f4fe2ba8ad1be7e74bffafebfea19014d7aa persistence.zh.md: 3030ff2fe949cb02385331800d826df227e3d6cd

View File

@@ -20,7 +20,7 @@ Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id
## `SessionLocation` — optional per-session artifact target ## `SessionLocation` — optional per-session artifact target
`SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. `SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns the absolute transcript path inside its project/session directory; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee.
```ts type-equiv ```ts type-equiv
/** /**

View File

@@ -20,7 +20,7 @@
## `SessionLocation`——可选的逐会话产物目标 ## `SessionLocation`——可选的逐会话产物目标
`SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立产物,而不会读取、创建或 flush 它。JSONL 返回其绝对目标路径SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。 `SessionPersistence.locate(meta)` 会同步解析一个归后端所有的独立产物,而不会读取、创建或 flush 它。JSONL 返回其项目/会话目录内 transcript文本记录绝对路径SQLite 因各会话共享一个数据库而返回 `undefined`。因此,返回的路径可能指向尚不存在、或还不包含当前尚未 flush 的轮次;它是位置提示,不是授权或新鲜度保证。
```ts type-equiv ```ts type-equiv
/** /**

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
subagent.md: 0335a3f0780ae17b57ae730f5a49a269261c8073 subagent.md: 2497dbab9cfc8304eb7aaeba7109404ac614bbff
subagent.zh.md: dac48b624f6e0cfc28737e3e1a2774ba2d97e85b subagent.zh.md: 2d96e9bc635951746e72ed58a7c3638dc2598cc2

View File

@@ -19,16 +19,13 @@ A provider advertises its **start-time** features on a static descriptor the ser
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
* degradation" rule). These static flags cover features needed before a run exists; runtime * degradation" rule). These static flags cover features needed before a run exists; runtime
* capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence
* is the capability. * is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option:
* `depthLimit` to `maxDepth`; the other names match.
*/ */
interface SubagentCapabilities { interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
readonly persona: boolean readonly persona: boolean
} }
``` ```
@@ -45,16 +42,12 @@ The tool layer builds this request from the model input and its own config; the
* passes it to {@link SubagentProvider.start}. * passes it to {@link SubagentProvider.start}.
*/ */
interface SubagentStartRequest { interface SubagentStartRequest {
/** The task/prompt for the child agent (a user message in the child session). */ /** Content delivered as the child's user message. */
readonly prompt: ContentBlock[] readonly prompt: ContentBlock[]
/** /**
* The spawning ("parent") agent — the one whose tool call started this * The spawning agent. In-process providers derive workspace, lineage, and
* subagent. REQUIRED: in-process backends read `parent.session.header` for * delegation depth from its durable session state. ACP reads only its cwd,
* the working directory, the `parentSession` lineage to stamp on the child, * and only when no deployment `cwd` override is configured.
* and the parent's delegation depth. The out-of-process backend (ACP) reads
* exactly one field — the session header's cwd, the child's workspace when
* no deployment `cwd` override is configured; nothing else crosses the
* process boundary.
*/ */
readonly parent: Agent readonly parent: Agent
/** /**
@@ -65,7 +58,6 @@ interface SubagentStartRequest {
* afterward. * afterward.
*/ */
readonly signal: AbortSignal readonly signal: AbortSignal
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions readonly agentOptions?: AgentOptions
/** /**
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
@@ -137,9 +129,9 @@ interface SubagentResult {
interface SubagentStopReasonMap { interface SubagentStopReasonMap {
/** The child finished its turn normally. */ /** The child finished its turn normally. */
completed: 'completed' completed: 'completed'
/** The run was cancelled by its request signal or by disposal. */ /** Cancelled through the request signal or disposal. */
aborted: 'aborted' aborted: 'aborted'
/** The child failed (model error, transport error). */ /** Model or transport failure. */
error: 'error' error: 'error'
/** The child hit its token ceiling before finishing. */ /** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens' 'max-tokens': 'max-tokens'
@@ -180,9 +172,8 @@ interface SubagentRun {
*/ */
readonly result: Promise<SubagentResult> readonly result: Promise<SubagentResult>
/** /**
* Cancel remaining work, reach child quiescence, and release the run's * Cancel remaining work, reach child quiescence, and release resources.
* resources (in-process: dispose the owned agent and remove its session; * Idempotent.
* ACP: kill and reap the subprocess). Idempotent.
*/ */
dispose(): Promise<void> dispose(): Promise<void>
/** /**
@@ -206,12 +197,9 @@ Each provider is a named child-agent transport, and multiple providers may coexi
```ts type-equiv ```ts type-equiv
/** /**
* A subagent backend: one transport for running a child agent (in-process * One registered transport for running child agents. Providers are trusted
* spawn/fork, ACP to another process, …). Implementations register under a * same-process implementations; callers treat descriptors and returned values
* unique name via {@link SubagentService.registerProvider}; multiple providers * as borrowed immutable data.
* coexist in one context (unlike the single-implementation bash seam). The
* Providers are trusted same-process implementations; callers treat their
* descriptors and returned values as borrowed immutable data.
*/ */
interface SubagentProvider { interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */

View File

@@ -19,16 +19,13 @@ subagent seam一个 agent智能体将工作委派给子 agent。与 [ba
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
* degradation" rule). These static flags cover features needed before a run exists; runtime * degradation" rule). These static flags cover features needed before a run exists; runtime
* capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence
* is the capability. * is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option:
* `depthLimit` to `maxDepth`; the other names match.
*/ */
interface SubagentCapabilities { interface SubagentCapabilities {
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
readonly outputSchema: boolean readonly outputSchema: boolean
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
readonly depthLimit: boolean readonly depthLimit: boolean
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
readonly toolFilter: boolean readonly toolFilter: boolean
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
readonly persona: boolean readonly persona: boolean
} }
``` ```
@@ -45,16 +42,12 @@ interface SubagentCapabilities {
* passes it to {@link SubagentProvider.start}. * passes it to {@link SubagentProvider.start}.
*/ */
interface SubagentStartRequest { interface SubagentStartRequest {
/** The task/prompt for the child agent (a user message in the child session). */ /** Content delivered as the child's user message. */
readonly prompt: ContentBlock[] readonly prompt: ContentBlock[]
/** /**
* The spawning ("parent") agent — the one whose tool call started this * The spawning agent. In-process providers derive workspace, lineage, and
* subagent. REQUIRED: in-process backends read `parent.session.header` for * delegation depth from its durable session state. ACP reads only its cwd,
* the working directory, the `parentSession` lineage to stamp on the child, * and only when no deployment `cwd` override is configured.
* and the parent's delegation depth. The out-of-process backend (ACP) reads
* exactly one field — the session header's cwd, the child's workspace when
* no deployment `cwd` override is configured; nothing else crosses the
* process boundary.
*/ */
readonly parent: Agent readonly parent: Agent
/** /**
@@ -65,7 +58,6 @@ interface SubagentStartRequest {
* afterward. * afterward.
*/ */
readonly signal: AbortSignal readonly signal: AbortSignal
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions readonly agentOptions?: AgentOptions
/** /**
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects * Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
@@ -137,9 +129,9 @@ interface SubagentResult {
interface SubagentStopReasonMap { interface SubagentStopReasonMap {
/** The child finished its turn normally. */ /** The child finished its turn normally. */
completed: 'completed' completed: 'completed'
/** The run was cancelled by its request signal or by disposal. */ /** Cancelled through the request signal or disposal. */
aborted: 'aborted' aborted: 'aborted'
/** The child failed (model error, transport error). */ /** Model or transport failure. */
error: 'error' error: 'error'
/** The child hit its token ceiling before finishing. */ /** The child hit its token ceiling before finishing. */
'max-tokens': 'max-tokens' 'max-tokens': 'max-tokens'
@@ -182,9 +174,8 @@ interface SubagentRun {
*/ */
readonly result: Promise<SubagentResult> readonly result: Promise<SubagentResult>
/** /**
* Cancel remaining work, reach child quiescence, and release the run's * Cancel remaining work, reach child quiescence, and release resources.
* resources (in-process: dispose the owned agent and remove its session; * Idempotent.
* ACP: kill and reap the subprocess). Idempotent.
*/ */
dispose(): Promise<void> dispose(): Promise<void>
/** /**
@@ -208,12 +199,9 @@ interface SubagentRun {
```ts type-equiv ```ts type-equiv
/** /**
* A subagent backend: one transport for running a child agent (in-process * One registered transport for running child agents. Providers are trusted
* spawn/fork, ACP to another process, …). Implementations register under a * same-process implementations; callers treat descriptors and returned values
* unique name via {@link SubagentService.registerProvider}; multiple providers * as borrowed immutable data.
* coexist in one context (unlike the single-implementation bash seam). The
* Providers are trusted same-process implementations; callers treat their
* descriptors and returned values as borrowed immutable data.
*/ */
interface SubagentProvider { interface SubagentProvider {
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */

View File

@@ -0,0 +1,41 @@
# Keyless replay for the retry overlay: disable the key-requiring DeepSeek
# adapter, insert `llm-replay`, and restate the app config with the same
# deterministic 1 ms zero-jitter retry policy as the live sibling. A config
# patch replaces the whole app config, so the base fields are restated
# verbatim (raw JSONL persistence so the harness can harvest the log).
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: none
workspaceContext:
maxBytes: 65536
llmRetry:
maxTransientRetries: 2
initialDelayMs: 1
maxDelayMs: 1
jitterRatio: 0
persona: |
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
Verify your work by running the code or tests. Keep answers brief and factual.
- insert:
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
providers:
- id: deepseek
name: DeepSeek
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro

View File

@@ -0,0 +1,30 @@
# Retry-scenario overlay: pin the bounded transient retry policy to a
# deterministic 1 ms zero-jitter delay so the durable `llm/retry` event
# (`delayMs`) and replay wall time stay reproducible. The overlay changes no
# tool or prompt composition, so its scenarios share the default header class.
# A config patch replaces the whole app config, so the base fields are restated
# verbatim; the model is re-pinned to `deepseek-v4-flash` like the other
# snapshot overlays because the recorded corpus was captured on flash.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: acp-agent
name: '@deepseek-ai/dsh-acp-demo'
config:
provider: deepseek
model: deepseek-v4-flash
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
workspaceContext:
maxBytes: 65536
llmRetry:
maxTransientRetries: 2
initialDelayMs: 1
maxDelayMs: 1
jitterRatio: 0
persona: |
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
Verify your work by running the code or tests. Keep answers brief and factual.

View File

@@ -114,7 +114,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over
}) })
expect(['end_turn', 'max_tokens']).toContain(res.stopReason) expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
// Verify the WORLD, not the agent's self-report: read the file from disk. // Assert the filesystem effect independently of the model response.
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
expect(proof).toContain('ACP_OK') expect(proof).toContain('ACP_OK')

View File

@@ -38,6 +38,7 @@ const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url))
const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url))
const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml', import.meta.url)) const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml', import.meta.url))
const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url)) const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url))
const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url))
const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url))
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
@@ -112,6 +113,14 @@ const SCENARIOS: Scenario[] = [
{ name: 'fs-policy-reject', hasModelTurn: true, recorded: true }, { name: 'fs-policy-reject', hasModelTurn: true, recorded: true },
{ name: 'multi-turn', hasModelTurn: true, recorded: true }, { name: 'multi-turn', hasModelTurn: true, recorded: true },
{ name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true }, { name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true },
// Keyless, authored (like error-finish): a live provider cannot be coaxed
// into a degenerate empty completion, so the fixture scripts the adapters'
// EMPTY_RESPONSE error finish (step 1) followed by the recovered reply
// (step 2), proving the default retry policy end to end: the durable
// llm/retry event, no ACP output for the discarded attempt, the recovered
// reply, and a clean completed turn. Its overlay only pins a deterministic
// 1 ms zero-jitter delay, so it shares the default header class.
{ name: 'empty-response-retry', hasModelTurn: true, recorded: false, configPath: RETRY_CONFIG },
// Keyless, authored (like error-finish/cancel): deterministically forcing a // Keyless, authored (like error-finish/cancel): deterministically forcing a
// LIVE model to repeat one call three times is not a stable recording, so // LIVE model to repeat one call three times is not a stable recording, so
// the fixture scripts five identical todo_write calls and pins BOTH reminder // the fixture scripts five identical todo_write calls and pins BOTH reminder

View File

@@ -62,7 +62,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook
// the model, not a turn failure). // the model, not a turn failure).
expect(['end_turn', 'max_tokens']).toContain(res.stopReason) expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
// Verify that the denied hook left no filesystem effect. // Assert the denied operation independently of the model response.
await expect(access(join(workdir, 'proof.txt'))).rejects.toThrow() await expect(access(join(workdir, 'proof.txt'))).rejects.toThrow()
// ACP publishes only the committed answer; hook/tool trace stays in the session log. // ACP publishes only the committed answer; hook/tool trace stays in the session log.

View File

@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "This prompt first receives an empty completion, then a retried reply." }
]
}

View File

@@ -0,0 +1,19 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"This prompt first receives an empty completion, then a retried reply."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":0,"data":{"title":"This prompt first receives an","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":0,"outputTokens":0}}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}}}
{"type":"step/end","seq":7,"time":0,"data":{"turn":1,"step":1}}
{"type":"llm/retry","seq":8,"time":0,"data":{"turn":1,"step":1,"retry":1,"maxRetries":2,"delayMs":1,"failure":{"message":"model returned a completed response with no content","code":"EMPTY_RESPONSE"}}}
{"type":"step/start","seq":9,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Recovered."}}}
{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Recovered."}}}}
{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"Recovered."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":12,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}
{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Recovered."}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -1,10 +1,7 @@
/** /**
* Browser half of the wire consumer layer (contract: api-contracts v3 * Browser wire client. The plugin selects fixture or HTTP transport, provides
* section 3; export inventory = v3 §3.2). The wire is this package's client * the shared API client, and lets the runtime object layer start the stream
* half in its entirety — apply mounts ctx.connection: the shared api client * controller with its sinks.
* plus the connection controller handle. Mode selection (?fixture) happens
* here so the rest of the client tree is mode-blind; the controller's sinks
* are wired by the runtime plugin (object layer), which injects this service.
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { IApiClient } from './api.ts' import type { IApiClient } from './api.ts'
@@ -23,9 +20,8 @@ export type {
} from './api.ts' } from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts' export { RpcId, AbstractApiClient, transportError } from './api.ts'
// ---- Connection loop types (part of the ConnectionHandle.start contract; // Connection loop types are public through ConnectionHandle.start; the
// the controller class itself stays package-internal — apply owns the loop, // controller remains package-internal.
// tests reach it via src) ----
export type { ConnectionConfig, ConnectionSinks, ConnectionState } export type { ConnectionConfig, ConnectionSinks, ConnectionState }

View File

@@ -1,12 +1,6 @@
/** /** Host HTTP bridge for browser-client RPC. */
* Connection plugin, node half: the host end of the web transport. Registers
* the /api prefix route on the web server and bridges node:http requests to
* the transport-agnostic fetch-shaped api handler. The wire consumer layer
* lives in the client half (src/client/ — contract: api-contracts v3
* section 3); consumers import the /client subpath.
*/
import type { Context } from 'cordis' import type { Context } from 'cordis'
// Type-only route import; it also carries the httpServer Context merge. // Activates the httpServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts' import { API_PATH } from './api-path.ts'
@@ -14,16 +8,15 @@ import { bridge } from './http-bridge.ts'
export { API_PATH } from './api-path.ts' export { API_PATH } from './api-path.ts'
/** Cordis plugin name. */ /** Stable Cordis plugin name. */
export const name = 'client-connection' export const name = 'client-connection'
/** Required services: the route registry and the api gateway. */ /** Services required before mounting the route. */
export const inject = ['httpServer', 'apiProxy'] export const inject = ['httpServer', 'apiProxy']
/** /**
* Mount the /api transport: wrap the api gateway into a fetch handler and * Mounts the API gateway under the browser transport prefix.
* serve it under the /api prefix. * @param ctx - Host plugin context.
* @param ctx - host plugin context carrying httpServer and apiProxy.
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
const apiHandler = toFetchHandler(ctx.apiProxy) const apiHandler = toFetchHandler(ctx.apiProxy)

View File

@@ -1,15 +1,10 @@
/** /**
* i18n plugin, browser half: namespace x locale dictionary registry with a * Browser-side locale registry. Bound translation functions retain stable
* bound translate function whose reference is stable (safe for inject * identity for injected consumers.
* surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
* Contract: api-contracts v3 section 8.
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'
// The snapshot-store engine lives in runtime (store relocation): framework // Snapshot stores are framework-neutral; React consumers bind hooks at their
// data stores like this locale cell use it directly. The store carries no // rendering boundary.
// hook — a React consumer binds a selector hook via web-react's
// bindSnapshotSelector at its own seam (none exists today; the current
// consumers are translate() reads and test-side subscribe/set).
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts' import { en } from '../locales/en.ts'

View File

@@ -1,11 +1,4 @@
/** /** Host loader entry for the browser implementation exported from `./client`. */
* i18n plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Everything else —
* I18nService, Translate, LocaleDict — lives in the client half; consumers
* import the /client subpath. Contract: api-contracts v3 section 8.
*/
/** Host plugin body — no host-side behavior for the i18n plugin. */ /** Host plugin body — no host-side behavior for the i18n plugin. */
export function apply(): void {} export function apply(): void {}

View File

@@ -161,11 +161,7 @@ function deepFreeze(value: unknown): void {
} }
} }
// ---- defineStore shell (slot terminal design §4) ---- // ui-slots owns the contract; this module supplies the engine implementation.
// The type authority is ui-slots' store family (create(scopeKey?) and
// clearPersisted() included); this module houses only the engine-backed
// implementation. The one engine-side widening left: instances expose the
// raw engine store for framework/test surfaces.
/** A live engine instance: the contract instance plus the raw engine store. */ /** A live engine instance: the contract instance plus the raw engine store. */
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> { export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {

View File

@@ -1,12 +1,7 @@
/** /**
* Browser half: the whole runtime contract surface (api-contracts v3 §4) — * Browser runtime services for slots, sessions, and connection-stream
* SlotsService (declaration ledger + renderer seam + store axis, built-in * delivery. The web shell mounts this static client entry through the host
* 'root'), SessionsService (list store + current selection + scope tree + * plugin graph.
* object layer), and the cordis Context/Events merges. apply mounts
* ctx.slots + ctx.sessions and wires the connection stream loop into the
* object layer. A static-arrival entry: the web shell bundles this module
* and mounts it through the host graph (module loading lives in
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
@@ -17,15 +12,11 @@ import type { SessionListState } from './sessions/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
export { SlotsService } from './slots.ts' export { SlotsService } from './slots.ts'
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
// ui-layout: the framework slot is declared by the framework package).
export type { RootOwnerProps } from './slots.ts' export type { RootOwnerProps } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts' export { SessionsService, scopeOf } from './sessions/service.ts'
export type { Session } from './sessions/session.ts' export type { Session } from './sessions/session.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
// The snapshot-store engine lives here since the store migration (the data // Runtime owns the snapshot store; web-react only binds it to React.
// layer owns its substrate; web-react is React glue only). The './client'
// main export is the single serving door — no store subpath.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type { export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
@@ -35,21 +26,11 @@ export type {
RunningToolCall, SteeringMessageNode, RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts' } from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts' export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
// ---- Narrowed aliases (the single narrowing point of the slot type chain: /** Client-side Cordis context after declaration merging. */
// ui-slots/web-react stay generic and dependency-inverted; the client-tree
// concrete types live here, where their subjects live) ----
/**
* The client cordis context face: the base Context plus the service keys
* this package's declaration merge contributes (slots/sessions/loader) and
* every later plugin's merge. A plain alias — the merges land on Context
* itself inside the client program; the name marks intent at consumer seams.
*/
export type ClientContext = Context export type ClientContext = Context
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */ /** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
@@ -69,14 +50,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* every session-scope slot component receives these from the framework. * every session-scope slot component receives these from the framework.
*/ */
interface SessionStandardProps { interface SessionStandardProps {
/** Selector hook over this session's conversation snapshot. */
useSession: SnapshotSelectorHook<ConversationSnapshot> useSession: SnapshotSelectorHook<ConversationSnapshot>
/** The framework-resolved session id (owners never pass it). */ /** The framework-resolved session id (owners never pass it). */
sessionId: SessionId sessionId: SessionId
} }
/** Global standard kit, real members: the session-list hook every slot component receives. */ /** Props injected into every global slot component. */
interface GlobalStandardProps { interface GlobalStandardProps {
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
useSessions: SnapshotSelectorHook<SessionListState> useSessions: SnapshotSelectorHook<SessionListState>
} }
} }
@@ -99,9 +78,8 @@ declare module 'cordis' {
/** Required services: the wire handle mounted by the connection plugin. */ /** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection'] export const inject = ['connection']
/** /** Mounts the browser runtime services and connection stream.
* Client plugin body: mount slots + sessions, start the stream loop. * @param ctx - Client Cordis context.
* @param ctx - client cordis context.
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
ctx.plugin(SlotsService) ctx.plugin(SlotsService)

View File

@@ -24,10 +24,10 @@ export interface CallIndexEntry {
callView: ToolCallView | null callView: ToolCallView | null
} }
/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch). /** Non-surface sentinel used to preserve paged-window sequence offsets.
* 'noop/padding' is not a real event type on purpose: a genuine type with fake data would * `noop/padding` is deliberately not a real event type, so it cannot acquire
* surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one * surface behavior; this cast is the only synthetic event entry point.
* place a synthetic event enters the window). */ */
function paddingEvent(seq: number): SessionEvent { function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
} }

View File

@@ -291,8 +291,7 @@ export class SessionsService {
fiber, fiber,
ctx, ctx,
binding: { sessionId: id, session, ctx }, binding: { sessionId: id, session, ctx },
// Bare source form (store migration): the Session object IS the // Session is the observable; React binds a selector hook at its own seam.
// observable; the React side binds the useSession hook per cell.
cell: { sessionId: id, session }, cell: { sessionId: id, session },
} }
this.scopes.set(id, record) this.scopes.set(id, record)

View File

@@ -1,7 +1,4 @@
// Session: wraps every contract call that needs a sessionId + all conversation state for this // Sessions remain resident after creation so they continue consuming mux frames off-screen.
// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
// created, they keep consuming mux frames in the background; React connects directly via
// subscribe/getSnapshot.
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
@@ -22,14 +19,12 @@ import { FoldAdapter } from './fold-adapter.ts'
import { Notifier } from './notifier.ts' import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts' import { PartialAccumulator } from './partial.ts'
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */ /** Messages requested per history page. */
export const PAGE_MESSAGES = 50 export const PAGE_MESSAGES = 50
/** /**
* Per-session state owner: event window + fold + partial, snapshot out via * Owns a session's event window, derived conversation state, and observable
* subscribe/getSnapshot (see the web client architecture RFC). Bare source * snapshot. React bindings remain outside this data layer.
* only (store migration): the React machinery binds the per-cell useSession
* hook at its own seam — no selector hook member lives on the data layer.
*/ */
export class Session implements ObservableSnapshot<ConversationSnapshot> { export class Session implements ObservableSnapshot<ConversationSnapshot> {
// ---- Window and derived state (all private; the snapshot is the only read surface) ---- // ---- Window and derived state (all private; the snapshot is the only read surface) ----
@@ -54,8 +49,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private frozenNodes: ConversationNode[] = [] private frozenNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>() private pending = new Map<string, PendingInteraction>()
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2, // Revision counters preserve array identity when derived content is unchanged, so
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
// tool card and pending card). Mutation sites bump the matching revision. partial needs no // tool card and pending card). Mutation sites bump the matching revision. partial needs no
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged. // counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
@@ -69,9 +63,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private removed = false private removed = false
private promptError: PromptError | null = null private promptError: PromptError | null = null
private lastAgentError: string | null = null private lastAgentError: string | null = null
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */ /** Live events buffered during open/resync and stitched by sequence once history lands. */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = [] private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */ /** Gap repair in flight; live events detour to the buffer until the tail page lands. */
private stitching = false private stitching = false
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */ /** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
private subscribedLastSeq: number | null = null private subscribedLastSeq: number | null = null
@@ -292,8 +286,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.notifier.markDirty() this.notifier.markDirty()
} }
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed /** No-op because session instances remain resident. */
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
dispose(): void {} dispose(): void {}
// ---- 私有 ---- // ---- 私有 ----

View File

@@ -1,11 +1,4 @@
/** /** Host loader entry for the browser runtime exported from `./client` and `./loader`. */
* Runtime plugin, node half. The implementation lives entirely in the client
* half (src/client/ — SlotsService, SessionsService + object layer, and the
* shell-held ClientLoader under ./loader); consumers import the /client or
* /loader subpaths. The empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery). Contract:
* api-contracts v3 section 4.
*/
/** Host plugin body — no host-side behavior for the runtime plugin. */ /** Host plugin body — no host-side behavior for the runtime plugin. */
export function apply(_ctx: unknown): void {} export function apply(_ctx: unknown): void {}

View File

@@ -187,8 +187,7 @@ describe('cell (render-layer session kit)', () => {
const cell = b.svc.cell('s1') const cell = b.svc.cell('s1')
expect(cell).toBeDefined() expect(cell).toBeDefined()
expect(cell?.sessionId).toBe('s1') expect(cell?.sessionId).toBe('s1')
// Bare-source form (store migration): the cell carries the Session // Hook binding happens in React; the cell carries the observable itself.
// observable itself; hook binding happens in the React machinery.
expect(cell?.session).toBe(b.svc.manager.get(sid('s1'))) expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
expect(b.svc.cell('s1')).toBe(cell) expect(b.svc.cell('s1')).toBe(cell)
expect(b.svc.cell('ghost')).toBeUndefined() expect(b.svc.cell('ghost')).toBeUndefined()

View File

@@ -1,14 +1,4 @@
/** /** Registers the conversation components, shared store, and service callbacks. */
* Client plugin body: register the conversation/details slot occupants and
* the no-session empty state, contribute the chat entry into the
* 'conversation.view' ring that the conversation registration declares, then
* mount the conversation service (class plugin) and the bash toolview sample.
* Assembly only — components receive everything through props: the framework
* standard kit and store faces arrive automatically from the declarations
* below; the inject factories contribute the plain-data-and-callbacks
* business face (design §5). Tool rows are ordinary keyed-slot registrations
* into 'conversation.chat.toolview' — no dedicated registry exists.
*/
import type { Context } from 'cordis' import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
@@ -25,7 +15,7 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { EmptyState } from './skeleton/EmptyState.tsx' import { EmptyState } from './skeleton/EmptyState.tsx'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ /** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions'] export const inject = ['slots', 'layout', 'sessions']
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */ /** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
@@ -37,24 +27,17 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
return conversation return conversation
} }
/** /** Mounts the conversation plugin.
* Client plugin body. * @param ctx - Client root context.
* @param ctx - client root context.
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
const sessions = ctx.sessions const sessions = ctx.sessions
const layout = ctx.layout const layout = ctx.layout
const slots = ctx.slots const slots = ctx.slots
// Shared store handle, constructed here so its identity lives and dies with // Apply-time construction keeps store identity bound to this fiber.
// this fiber (a module-level handle would be a de-facto singleton). The
// conversation, chat-view, and details registrations all declare it; same
// scope key = same instance, so chat-view selection writes and details
// reads meet in one store.
const chatStore = createChatStore() const chatStore = createChatStore()
// Tab projection over the view ring's ledger (list entries carry id/order/
// label as registration options; the ledger keeps them order-sorted).
const viewTabs = (): ViewTab[] => { const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = [] const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) { for (const entry of slots.entries('conversation.view')) {

View File

@@ -1,9 +1,4 @@
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284 // Settled-node identity prevents stream-delta updates from rerendering this row.
// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow
// (part of the chat view body — the chrome attachment mechanism retired with
// the view ring). Duration has no data source in P-I (ledger). Subscribes to
// `nodes` only: chunk batches never swap that reference, so the row renders
// zero times during streaming (the RFC performance model's acceptance row).
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'

View File

@@ -1,15 +1,4 @@
/** /** Conversation slot declarations and their composed component props. */
* Slot-ring contract for the conversation package: the 'conversation.view'
* slot this package declares (the view ring — one list entry per conversation
* view tab), the chat view's per-tool row hole ('conversation.chat.toolview',
* keyed on the wire tool name), and the composed props shapes its registrants
* mount into the layout-owned slots (conversation / details /
* conversation.empty) plus its own slots. Terminal slot design (§3): full
* component props are the automatic shares — PropsRuntime<K> (framework
* standard kit) & PropsRenderSlots<S> (declared children) & PropsStore<H>
* (declared store's read/write faces) & the injected business face declared
* here.
*/
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts' import type { createChatStore } from '../stores.ts'
@@ -93,15 +82,9 @@ export type ConvViewProps = PropsRuntime<'conversation.view'>
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */ /** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
export type ChatStore = ReturnType<typeof createChatStore> export type ChatStore = ReturnType<typeof createChatStore>
/** /** Business callbacks injected into the conversation slot. */
* Injected share of the conversation slot: plain data and callbacks only
* (design §5 — hooks are framework-made). The store lines that used to ride
* here live in the declared {@link ChatStore}; ancestry derives from the
* standard useSessions hook in-component; views render through the declared
* 'conversation.view' child slot, with this face projecting the tab strip.
*/
export interface ConversationInjected { export interface ConversationInjected {
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */ /** Views projected from the `conversation.view` slot ledger. */
views: { views: {
list(): readonly ViewTab[] list(): readonly ViewTab[]
subscribe(fn: () => void): () => void subscribe(fn: () => void): () => void
@@ -111,7 +94,6 @@ export interface ConversationInjected {
send(text: string, mode: 'queue' | 'steer'): void send(text: string, mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void stop(): void
/** Navigate to another session (breadcrumb ancestors). */
open(id: SessionId): void open(id: SessionId): void
} }
@@ -123,7 +105,6 @@ export interface ConversationInjected {
* with zero owner changes. * with zero owner changes.
*/ */
export interface ComposerChainProps { export interface ComposerChainProps {
/** The session's live pending waits, in arrival order (snapshot reference). */
interactions: readonly PendingInteraction[] interactions: readonly PendingInteraction[]
} }
@@ -139,7 +120,6 @@ export type ConversationSlotProps =
export interface ChatViewInjected { export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */ /** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void openDetails(target: SelectionTarget): void
/** Pull one older history page. */
loadOlder(): void loadOlder(): void
} }

View File

@@ -1,14 +1,4 @@
/** /** Shared conversation view, selection, and store-state contracts. */
* Shared conversation contract primitives: the view tab projection (slot
* entries in 'conversation.view' surface as tabs), the chat store state
* shared through the declared store, and the selection primitives every
* domain consumes. Shared face between the skeleton domain (tab strip +
* view outlet) and the chat domain; domain implementation files import this,
* never each other. The view ring itself IS the 'conversation.view' slot
* (contract in slots.ts) — the package-local view registry is retired, and
* so is the hand-threaded translate channel (framework-level per-slot i18n
* injection is the planned replacement).
*/
/** Tool call identity as carried on the wire (branded upstream in connection). */ /** Tool call identity as carried on the wire (branded upstream in connection). */
export type CallId = string export type CallId = string
@@ -23,11 +13,8 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C
export interface ViewTab { id: string; label: string } export interface ViewTab { id: string; label: string }
/** /**
* Chat store state (slot terminal design §4): the per-session store shared by * Per-session state shared by conversation, chat-view, and details slots.
* the conversation, chat-view, and details registrations. `createChatStore` * Unknown persisted view ids fall back to the first registered view.
* implements this shape. `view` may carry a stale persisted id after a view
* plugin unloads — the slot ledger is the runtime validator (unknown ids fall
* back to the first registered view).
*/ */
export interface ChatStoreState { export interface ChatStoreState {
/** Details-linkage channel (conversation writes, details reads). */ /** Details-linkage channel (conversation writes, details reads). */

View File

@@ -1,12 +1,7 @@
/** /**
* Conversation domain plugin, browser half: skeleton (header/tabs/composer), * Browser conversation plugin. `contract/` is the shared type boundary
* the 'conversation.view' slot ring (chat entry here; other plugins * between the independently implemented skeleton and chat domains; `apply.ts`
* contribute view tabs through ctx.slots), the chat view's keyed * owns their slot assembly.
* 'conversation.chat.toolview' row hole, scope-addressed ConversationService,
* minimal details panel. Contract: api-contracts v3 section 7. Thin shell:
* type surfaces live in contract/, assembly in apply.ts; the implementation
* domains (skeleton/chat) never import each other — contract/ is their only
* shared face.
*/ */
import type { ConversationService } from './service.ts' import type { ConversationService } from './service.ts'

View File

@@ -1,17 +1,11 @@
/** /**
* ConversationService implementation: scope-addressed send/cancel and the * Scope-addressed conversation send, cancel, and empty-state session startup.
* empty-state startSession chain. Contract: api-contracts v3 section 7.
* Selection/draft state moved to the declared chat store (slot terminal
* design §4); the view registry moved to the 'conversation.view' slot (slot
* ledger owns registration, ordering, and disposal) — what remains is the
* send/stop orchestration face.
* *
* Scope addressing rides the cordis Service tracker: property access through * Scope addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods * `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
* read the session tag with scopeOf (same mechanism as the host tool * read the session tag with `scopeOf`. Mutable state must remain reachable
* registry). Mutable state lives in plain objects reached by one property * through one property read; assignment through the tracker proxy and `#`
* read — field assignment through the tracker's shadow proxy is off-limits, * private fields bypass that rebinding.
* as are `#` hard-private fields.
*/ */
import { Service } from 'cordis' import { Service } from 'cordis'
import type { Context } from 'cordis' import type { Context } from 'cordis'

View File

@@ -1,12 +1,5 @@
// InputBar: the one composer input (figma Input_Bottom). The same component // Shared empty-state and resident composer. Running retains the draft, locks
// serves the empty state (variant='hero': centered launch card) and the // the textarea, and exposes only Stop. Bottom controls are local visual state.
// resident composer (variant='composer') — the empty→content transition is a
// position move of this component, never a swap (layout ruling). Running
// LOCKS the input: textarea disabled with the draft visible, stop is the only
// action; the turn ending re-enables and refocuses.
//
// Bottom chrome (attach / Plan / Read-only / model) is visual-only for now —
// local native <select> state, no host wiring.
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
@@ -25,10 +18,8 @@ export interface InputBarProps {
running: boolean running: boolean
disabled: boolean disabled: boolean
error: InputBarError | null error: InputBarError | null
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer' variant: 'hero' | 'composer'
placeholder?: string placeholder?: string
/** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */
accessory?: ReactNode accessory?: ReactNode
onDraftChange: (text: string) => void onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void onSend: (mode: 'queue' | 'steer') => void

View File

@@ -1,21 +1,11 @@
/** /**
* Chat store factory (slot terminal design §4): selection + draft + active * Per-session chat store shared by conversation and details registrations.
* view for one session, shared by the conversation and details registrations * The plugin creates its handle at apply time so identity follows the fiber.
* (apply constructs one handle and passes it to both). Session-scope
* derivation: both mount slots are scope=session, so the framework creates
* one instance per session; the persist key is scope-suffixed by the
* framework, aligning with the previous per-session draft persistence.
*
* Module exports the factory only — a module-level handle would pin identity
* in the module cache (a de-facto singleton surviving plugin reloads).
*/ */
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget } from './contract/views.ts' import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
/** /** Declared action shape used to give the exported factory a stable return type. */
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
type ChatActions = { type ChatActions = {
select: (draft: ChatStoreState, target: SelectionTarget | null) => void select: (draft: ChatStoreState, target: SelectionTarget | null) => void
setDraft: (draft: ChatStoreState, text: string) => void setDraft: (draft: ChatStoreState, text: string) => void
@@ -25,18 +15,11 @@ type ChatActions = {
} }
/** /**
* Declare the per-session chat store. `selection` is the details-linkage * Declares the per-session chat state and write surface.
* channel (conversation writes, details reads); `draft` is the composer text * @returns the store handle.
* (persisted so it survives session switches and reloads); `view` is the
* active conversation view id (a 'conversation.view' entry id — store seat is
* the cross-remount survival channel, null falls back to the first view).
* @returns the store handle (spec + identity + factory in one value).
*/ */
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> { export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({ return defineStore({
// Anchored to the contract shape: consumers read the store through
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
// and the contract cannot drift.
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }), init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
persist: 'dsh.conversation.chat', persist: 'dsh.conversation.chat',
actions: { actions: {

View File

@@ -1,10 +1,4 @@
/** /** Host loader entry for the browser-only conversation plugin. */
* Conversation plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader (load and lifecycle
* follow the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 7.
*/
/** Host plugin body — no host-side behavior for the conversation plugin. */ /** Provides no host-side behavior. */
export function apply(): void {} export function apply(): void {}

View File

@@ -137,7 +137,6 @@ describe('conversation slot inject surface', () => {
expect(injected.views.list().map(v => v.id)).toEqual(['chat']) expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
injected.open(ROOT) injected.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
// loadOlder moved to the chat view entry's face (the ring rider).
const chatView = b.chatViewSurface(ROOT) const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder() chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)

View File

@@ -1,10 +1,5 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /** Chat-store actions, scoped persistence, and instance isolation. */
* createChatStore unit account (slot terminal design §4): the declared
* actions write set, persist round-trip through the scope-suffixed key, and
* factory purity (every create() is an independent instance; the factory
* itself holds no singleton state).
*/
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { createChatStore } from '../src/client/stores.ts' import { createChatStore } from '../src/client/stores.ts'

View File

@@ -146,8 +146,6 @@ describe('keyed toolview hole through the real machinery', () => {
it('a duplicate key registration fails loud at load', async () => { it('a duplicate key registration fails loud at load', async () => {
const b = await bench([]) const b = await bench([])
// The bash sample already holds the 'bash' key (later-wins retired with
// the ring — the keyed ledger throws instead).
expect(() => b.slots.register( expect(() => b.slots.register(
{ name: 'conversation.chat.toolview', key: 'bash' }, { name: 'conversation.chat.toolview', key: 'bash' },
() => null, () => null,

View File

@@ -69,7 +69,7 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
}) })
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */ /** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() { function emptySessions() {
const store = createSnapshotStore<SessionListState>( const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState) { ids: [], byId: {}, current: undefined } as SessionListState)

View File

@@ -1,9 +1,4 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// Final branch tails for the coverage gate, terminal slot form:
// AssistantMarkdown non-final reasoning, StatsLine usage-less node,
// DetailsPanel titleless selection. (The old cwd WeakMap-cache account
// retired with the mechanism — derivation lives in EmptyState now, covered
// by the skeleton specs.)
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react' import { cleanup, render } from '@testing-library/react'

View File

@@ -1,12 +1,6 @@
/** /**
* Test-local selector-hook binder: the engine carries no hook since the store * Test-local selector binding through the production uSES implementation.
* migration (runtime is React-free); the renderer binds in production, specs * Runtime remains React-free, so specs bind observable sources here.
* bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
* with-selector uSES shim as production, so selector-level render economics —
* a top-level snapshot swap with an unchanged slice does NOT re-render — hold
* in Profiler-count specs). Source-relative import: the package dependency
* edge to web-react is gone (store migration §7); tests reach the sibling
* package the same way they reach their own src internals.
*/ */
import { bindSnapshotSelector } from '../../web-react/src/bind.ts' import { bindSnapshotSelector } from '../../web-react/src/bind.ts'

View File

@@ -1,13 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* Selection survival across the store seat (terminal design §4): the chat * Exercises selection persistence through the real SlotsService store axis;
* store now carries what the per-scope selection account used to — this pins * component stubs cannot prove per-session identity or disposal.
* the same behavior contract in the new mechanism. Drives the REAL
* SlotsService store axis with the shared createChatStore handle (the exact
* apply.ts shape: one handle, two session-slot registrations): same session's
* two slots resolve one instance (conversation writes, details reads);
* sessions are isolated; a session's death buries its instance AND its
* persisted draft; a list refresh does not touch instance identity.
*/ */
import { Context } from 'cordis' import { Context } from 'cordis'
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
@@ -15,8 +9,7 @@ import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/c
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatStore } from '../src/client/stores.ts' import { createChatStore } from '../src/client/stores.ts'
// The runtime package's programmable fake lives in its tests; import through // Use the runtime's programmable fake to drive the real session service.
// the src path (same pattern the runtime specs use — test-support material).
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
const sid = (s: string): SessionId => s as SessionId const sid = (s: string): SessionId => s as SessionId

View File

@@ -1,10 +1,4 @@
/** /** Host loader entry for the browser-only layout plugin. */
* Layout plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 5.
*/
/** Host plugin body — no host-side behavior for the layout plugin. */ /** Provides no host-side behavior. */
export function apply(): void {} export function apply(): void {}

View File

@@ -42,7 +42,7 @@ class ResizeObserverStub {
let frameWidth = 1920 let frameWidth = 1920
/** Minimal selector hook over an engine instance (the engine carries no hook since the store migration; the renderer binds in production, the spec binds here). */ /** Test-local selector hook over a framework-neutral store instance. */
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
} }

View File

@@ -1,7 +1,5 @@
/** /**
* Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Modal/Input, * Cordis-free React primitives styled only through `--dsw-*` tokens.
* markdown family, ConnectionBanner. Everything consumes props plus --dsw-*
* token vars only. Contract: api-contracts v3 section 8.
*/ */
export { StateDot } from './StateDot.tsx' export { StateDot } from './StateDot.tsx'

View File

@@ -16,9 +16,7 @@ async function bench() {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SlotsService).await() await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService const slots = ctx.get('slots') as SlotsService
// Stand-in for ui-conversation's conversation entry: the composer slot only // The composer slot exists only while its declaring entry is live.
// exists while a live entry declares it in children (declaration account:
// design §2.2).
slots.register( slots.register(
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never, { name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
() => null, () => null,

View File

@@ -1,12 +1,5 @@
/** /**
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, WorkSpace * Collapse is a slide plus crossfade: content freezes at its expanded
* section header with the group-by menu, search, session tree list, Settings
* foot. Pure presentational — the session list arrives through the standard
* useSessions hook, viewing state (expansion, search) is local component
* state, and rows are derived in render via useMemo (slot design section 6:
* derived data is a pure function, no materializing store).
*
* Collapse is a slide + crossfade: the content freezes at its expanded
* width (inline style) and fades out in place while the sliding column * width (inline style) and fades out in place while the sliding column
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle * (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
* the wide-only content (brand, labels, input, tree) unmounts, dropping * the wide-only content (brand, labels, input, tree) unmounts, dropping
@@ -35,7 +28,7 @@ const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [ const GROUP_BY_ITEMS = [
{ id: 'workspace', label: 'WorkSpace' }, { id: 'workspace', label: 'WorkSpace' },
// Update/Status grouping has no design yet (figma §3) — visible, disabled. // Only workspace grouping is implemented.
{ id: 'update', label: 'Update', disabled: true }, { id: 'update', label: 'Update', disabled: true },
{ id: 'status', label: 'Status', disabled: true }, { id: 'status', label: 'Status', disabled: true },
] ]
@@ -78,8 +71,7 @@ type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen'
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) { function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
const list = useSessions((s) => s) const list = useSessions((s) => s)
// Wave-2 seam: row highlight expects `current` on the sessions list // Selection belongs to the sessions snapshot, not layout state.
// snapshot (sessions.current lives with the runtime sessions service).
const current = useSessions((s) => s.current) const current = useSessions((s) => s.current)
const [expandedProjects, setExpandedProjects] = useState<string[]>([]) const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([]) const [expandedSessions, setExpandedSessions] = useState<string[]>([])

View File

@@ -1,30 +1,19 @@
/** /** Registers the sidebar UI into the layout-owned slot. */
* Sidebar plugin, browser half: SidebarRoot registered into the layout-owned
* sidebar slot. Pure consumer — the session list arrives through the
* standard useSessions prop, tree rows derive in the component, and the
* inject surface is plain cross-service callbacks closed over the plugin's
* own ctx (slot design sections 5 and 6); props composition in
* contract/slots.ts. Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts' import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx' import { SidebarRoot } from './SidebarRoot.tsx'
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts' export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ /** Services required by the sidebar plugin. */
export const inject = ['slots', 'layout', 'sessions'] export const inject = ['slots', 'layout', 'sessions']
/** /** Registers the sidebar component and its service callbacks.
* Client plugin body: register SidebarRoot into the sidebar slot. The inject * @param ctx - Client root context.
* factory returns service callbacks only (no hooks, no store lines) — all
* data reads ride the framework's standard useSessions delivery.
* @param ctx - client root context.
*/ */
export function apply(ctx: ClientContext): void { export function apply(ctx: ClientContext): void {
const injectProps = (): SidebarRootInjected => ({ const injectProps = (): SidebarRootInjected => ({
// Selection lives with the runtime sessions service (current rides the // Selection belongs to the sessions service; layout owns only panel geometry.
// list snapshot); layout keeps only panel geometry.
onOpen: (id) => { ctx.sessions.open(id) }, onOpen: (id) => { ctx.sessions.open(id) },
onCreate: (cwd) => { onCreate: (cwd) => {
// Top-level New Session / New Workspace: clear selection so AppFrame // Top-level New Session / New Workspace: clear selection so AppFrame

View File

@@ -1,11 +1,4 @@
/** /** Pure derivation of flat sidebar rows from sessions and local view state. */
* Pure sidebar tree derivation: session list snapshot -> flat render rows.
* Groups sessions by project directory (cwd), builds the per-group session
* tree from parentId links, sorts by recency, and applies search filtering
* with forced ancestor visibility. Derived data is a pure function (slot
* design section 6): the component feeds the useSessions snapshot plus its
* local viewing state through useMemo — no materializing store.
*/
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
/** Group key for sessions without a project directory. */ /** Group key for sessions without a project directory. */

View File

@@ -1,10 +1,4 @@
/** /** Host loader entry for the browser-only sidebar plugin. */
* Sidebar plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 6.
*/
/** Host plugin body — no host-side behavior for the sidebar plugin. */ /** Provides no host-side behavior. */
export function apply(): void {} export function apply(): void {}

View File

@@ -36,8 +36,7 @@ async function bench() {
ctx.provide('sessions', sessions) ctx.provide('sessions', sessions)
ctx.provide('layout', layout) ctx.provide('layout', layout)
const slots = ctx.get('slots') as SlotsService const slots = ctx.get('slots') as SlotsService
// Stand-in for ui-layout's root entry: the sidebar slot only exists while // The sidebar slot exists only while its declaring entry is live.
// a live entry declares it in children (declaration account: design §2.2).
slots.register( slots.register(
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never, { name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
() => null, () => null,

View File

@@ -10,8 +10,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, useSyncExternalStore } from 'react' import { act, useSyncExternalStore } from 'react'
// Engine home: runtime/client since the store migration; the engine carries // Runtime is React-free, so the spec binds its selector locally.
// no hook (runtime is React-free), so the spec binds the selector locally.
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx' import { SidebarRoot } from '../src/client/SidebarRoot.tsx'

View File

@@ -142,13 +142,9 @@ export interface SessionAreaProps {
} }
/** /**
* The framework-wired session area component (slot terminal design §7): * Framework-wired session area component. It subscribes to runtime-owned
* subscribes to the current-session selection internally (design fiat ① — * session selection and is injected into entries that declare session-scoped
* selection authority lives with runtime sessions) and switches between the * children; business code does not import it directly.
* session body and the empty branch. Delivered as a standard seat to every
* entry whose children declaration contains a session-scope slot (the
* derivation rides {@link PropsRenderSlots}); the value is injected by the
* installed renderer — business code never imports it.
*/ */
export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
@@ -352,8 +348,7 @@ export class SlotCore {
/** /**
* Contribute a component to a declared slot and (optionally) declare child * Contribute a component to a declared slot and (optionally) declare child
* slots, a store seat, and the registrant's business face — the single * slots, a store seat, and the registrant's business face.
* composition API (the separate define API is retired).
* *
* Load-time validation (misconfiguration fails loud; the render hot path * Load-time validation (misconfiguration fails loud; the render hot path
* re-checks nothing): registering into an undeclared slot throws; declaring * re-checks nothing): registering into an undeclared slot throws; declaring

View File

@@ -1,10 +1,4 @@
/** /** React-free contracts between the slot host and an installed renderer. */
* Renderer install seam (slot terminal design §8): the SlotRenderer interface
* web-react's machinery implements, the host surface the runtime SlotsService
* presents to the installed renderer, and the render-path authorization
* errors. Pure types plus two error classes — this package stays React-free
* at runtime (React types only).
*/
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts' import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
@@ -22,7 +16,6 @@ export interface HostObservable<T> {
* typing lands at the component seam via {@link PropsStore}. * typing lands at the component seam via {@link PropsStore}.
*/ */
export interface StoreInstanceLike { export interface StoreInstanceLike {
/** Current state snapshot (uSES getSnapshot side). */
getSnapshot(): unknown getSnapshot(): unknown
/** /**
* Subscribe to state changes (uSES subscribe side). * Subscribe to state changes (uSES subscribe side).
@@ -30,7 +23,6 @@ export interface StoreInstanceLike {
* @returns unsubscribe. * @returns unsubscribe.
*/ */
subscribe(fn: () => void): () => void subscribe(fn: () => void): () => void
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: Record<string, (...params: never[]) => void> readonly actions: Record<string, (...params: never[]) => void>
} }
@@ -97,7 +89,7 @@ export interface SlotRendererHost {
sessions: { sessions: {
/** Session list source backing the useSessions standard hook. */ /** Session list source backing the useSessions standard hook. */
list: HostObservable<unknown> list: HostObservable<unknown>
/** Current-session source backing SessionProvider's self-wiring (design fiat ①). */ /** Current-session source used by SessionProvider. */
current: HostObservable<string | undefined> current: HostObservable<string | undefined>
/** /**
* Resolve the session standard kit. * Resolve the session standard kit.

View File

@@ -1,12 +1,4 @@
/** /** Framework-neutral store contracts for slot registrations and the runtime engine. */
* Store-seat type family (slot terminal design §4): a registrant declares its
* shared/exclusive business store as data — schema (`init`), optional
* persistence key, and the complete write set (`actions`) — and the framework
* owns instance lifecycle (scope derives from the mounting entry's slot).
* ui-slots ships the contract types only; the engine-backed `defineStore`
* value lives in web-react (the snapshot-store engine's home) and must
* satisfy {@link DefineStore}.
*/
/** /**
* Typed selector hook over a snapshot source. Canonical shape for the whole * Typed selector hook over a snapshot source. Canonical shape for the whole
@@ -41,11 +33,8 @@ export type BakedActions<T, A extends ActionsDecl<T>> = {
* and the actions write set. * and the actions write set.
*/ */
export interface StoreSpec<T, A extends ActionsDecl<T>> { export interface StoreSpec<T, A extends ActionsDecl<T>> {
/** Initial-state factory; called once per framework-created instance. */
init: () => T init: () => T
/** Opt-in persistence key (storage mechanics belong to the engine). */
persist?: string persist?: string
/** Complete write set: pure draft transforms. */
actions: A actions: A
} }
@@ -58,9 +47,7 @@ export interface StoreSpec<T, A extends ActionsDecl<T>> {
* call create() themselves — instance lifecycle is the framework's. * call create() themselves — instance lifecycle is the framework's.
*/ */
export interface StoreInstance<T, A extends ActionsDecl<T>> { export interface StoreInstance<T, A extends ActionsDecl<T>> {
/** Baked write callbacks (delivered to components as `actions`). */
readonly actions: BakedActions<T, A> readonly actions: BakedActions<T, A>
/** Current state snapshot (uSES getSnapshot side; test assertions). */
getSnapshot(): T getSnapshot(): T
/** /**
* Subscribe to state changes (uSES subscribe side). * Subscribe to state changes (uSES subscribe side).
@@ -84,7 +71,6 @@ export interface StoreInstance<T, A extends ActionsDecl<T>> {
* identity is a disguised singleton across plugin reloads. * identity is a disguised singleton across plugin reloads.
*/ */
export interface StoreHandle<T, A extends ActionsDecl<T>> { export interface StoreHandle<T, A extends ActionsDecl<T>> {
/** The inert declaration this handle was defined from. */
readonly spec: StoreSpec<T, A> readonly spec: StoreSpec<T, A>
/** /**
* Create a live engine instance (framework machinery and tests only). * Create a live engine instance (framework machinery and tests only).

View File

@@ -1,10 +1,6 @@
/** /**
* Theme plugin, browser half: ThemeService over the --dsw-* token base * Browser theme registry over the `--dsw-*` token stylesheets. Theme changes
* stylesheets in src/styles/ (the sole token source; components must not * update CSS variables and `body[data-ds-dark-theme]` without React renders.
* hardcode colors). apply(id) toggles body[data-ds-dark-theme] — theming is
* CSS cascade, zero React renders. Contract: api-contracts v3 section 8.
* The base stylesheets ship separately (the web shell imports them as base
* CSS); this plugin only owns the registry and the body-attribute switch.
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'

View File

@@ -1,11 +1,4 @@
/** /** Host loader entry for the browser implementation exported from `./client`. */
* Theme plugin, node half. Pure UI plugin: the empty apply exists so the
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
* the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). ThemeService and its
* types live in the client half; consumers import the /client subpath.
* Contract: api-contracts v3 section 8.
*/
/** Host plugin body — no host-side behavior for the theme plugin. */ /** Host plugin body — no host-side behavior for the theme plugin. */
export function apply(): void {} export function apply(): void {}

View File

@@ -1,9 +1,6 @@
/** /**
* Trajectory/Waterfall plugin, browser half: contributes the two placeholder * Browser trajectory plugin contributing two entries to the conversation
* views into the conversation view ring (the 'conversation.view' list slot * view slot without defining a service.
* declared by ui-conversation). Pure consumer — no ctx service, no Context
* declaration merge; the minimal-plugin exemplar. Contract: api-contracts v3
* section 8.
*/ */
import type { Context } from 'cordis' import type { Context } from 'cordis'
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's // Type-only: the 'conversation.view' SlotMap row (declared by the slot's
@@ -24,8 +21,7 @@ export const inject = ['slots', 'conversation']
/** /**
* Client plugin body: register the trajectory and waterfall view tabs. The * Client plugin body: register the trajectory and waterfall view tabs. The
* registrations ride the slot service's effect wrapper (plugin unload * registrations ride the slot service's effect wrapper (plugin unload
* removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps * removes both tabs).
* the span stats header inside its body (chrome attachment retired).
* @param ctx - client root context. * @param ctx - client root context.
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {

View File

@@ -1,10 +1,4 @@
/** /** Host loader entry for the browser-only trajectory plugin. */
* Trajectory plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader (load and lifecycle
* follow the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 8.
*/
/** Host plugin body — no host-side behavior for the trajectory plugin. */ /** Provides no host-side behavior. */
export function apply(): void {} export function apply(): void {}

View File

@@ -59,7 +59,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> } return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
} }
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id; engines carry no hook since the store migration — bind here). */ /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
function emptySessions() { function emptySessions() {
const store = createSnapshotStore<SessionListState>( const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState) { ids: [], byId: {}, current: undefined } as SessionListState)
@@ -173,7 +173,6 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall']) expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
// Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body.
expect(screen.queryByText(/turns ·/)).toBeNull() expect(screen.queryByText(/turns ·/)).toBeNull()
expect(screen.getByText('Turn 1')).toBeTruthy() expect(screen.getByText('Turn 1')).toBeTruthy()
expect(screen.getByText('Turn 2')).toBeTruthy() expect(screen.getByText('Turn 2')).toBeTruthy()

View File

@@ -1,13 +1,4 @@
/** /** React bindings for the framework-neutral slot and snapshot contracts. */
* Shell-side React glue (slot terminal design §8): createSlotRenderer (the
* install-seam implementation), SessionProvider (framework-wired render
* prop, also delivered as a standard seat to session-area entries),
* bindSnapshotSelector (the one hook constructor), and useInvoke. The
* snapshot-store engine and defineStore live in runtime (store relocation);
* contract types are ui-slots authority — this face re-exports only what its
* own values traffic in. React contexts stay in-package: business components
* see none.
*/
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
export { bindSnapshotSelector } from './bind.ts' export { bindSnapshotSelector } from './bind.ts'
@@ -20,7 +11,6 @@ export { bindSnapshotSelector } from './bind.ts'
*/ */
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap> export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
// -- renderer: the install-seam implementation; contract lives in ui-slots --
export type { export type {
ChainRenderOpts, HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook, ChainRenderOpts, HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
SlotRenderer, SlotRendererHost, StoreInstanceLike, SlotRenderer, SlotRendererHost, StoreInstanceLike,
@@ -28,7 +18,6 @@ export type {
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots' export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
export { createSlotRenderer } from './scoped-slots.tsx' export { createSlotRenderer } from './scoped-slots.tsx'
// -- session area: the framework-wired provider; binding contexts stay internal --
export { SessionProvider, SlotAssemblyError, type SessionProviderProps } from './session-provider.tsx' export { SessionProvider, SlotAssemblyError, type SessionProviderProps } from './session-provider.tsx'
export { useInvoke } from './use-invoke.ts' export { useInvoke } from './use-invoke.ts'

View File

@@ -1,19 +1,6 @@
/** /**
* createSlotRenderer(): the outlet machinery behind the runtime install seam * React renderer for declarative slots. Per-entry bindings enforce child
* (slot terminal design §8). renderRoot mounts the host channel and renders * authorization, and entry boundaries contain registrant failures.
* the built-in 'root' key; every deeper slot renders through a per-entry
* renderSlot binding synthesized from the entry's children declaration.
* Standard-kit synthesis per entry: the global useSessions hook, the session
* pair (useSession + sessionId) under SessionProvider, the store pair
* (useStore + actions) for store-declaring entries, the renderSlot binding
* (entry-identity bound, stale-checked) for children-declaring entries, and
* the renderSlotChain binding for entries declaring a chain-kind child
* (selector-routed: first non-null select elects and its value joins the
* props as `matched`; all-null falls to the owner fallback).
* Inject factories run inside the entry component bodies ON PURPOSE
* — the per-entry error boundary contains a throwing factory to its own
* entry; parameters follow the declaration (sessionId for session slots,
* baked actions when a store is declared).
*/ */
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react' import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import { import {
@@ -27,10 +14,8 @@ import {
type InjectedProps = Record<string, unknown> type InjectedProps = Record<string, unknown>
/** Owner-facing renderSlot binding shape (typed narrowing lands on the wave-1 props seam). */
type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode
/** Owner-facing renderSlotChain binding shape (typed narrowing lands on the props seam). */
type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode
/** /**

View File

@@ -1,11 +1,4 @@
/** /** Internal React bindings for the renderer host and active session cell. */
* SessionProvider (framework-wired render prop, slot terminal design §7) plus
* the two internal channels the render machinery shares: the renderer host
* context (written once by createSlotRenderer's root) and the per-session
* binding context (written here, read by session-scope outlets). Both
* contexts are in-package machinery — they are NOT exported from the package
* index; business components see zero React contexts.
*/
import { createContext, useContext, type ReactNode } from 'react' import { createContext, useContext, type ReactNode } from 'react'
import type { import type {
HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook, HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook,
@@ -20,7 +13,7 @@ import { bindSnapshotSelector } from './bind.ts'
*/ */
export class SlotAssemblyError extends Error {} export class SlotAssemblyError extends Error {}
/** Renderer host channel: written by createSlotRenderer's root element (in-package machinery only). */ /** In-package renderer host context. */
export const HostContext = createContext<SlotRendererHost | null>(null) export const HostContext = createContext<SlotRendererHost | null>(null)
/** /**
@@ -34,7 +27,6 @@ export function useHost(): SlotRendererHost {
return host return host
} }
/** Per-session binding channel for the subtree under SessionProvider (in-package machinery only). */
const BindingContext = createContext<SessionCell | null>(null) const BindingContext = createContext<SessionCell | null>(null)
/** /**
@@ -75,11 +67,10 @@ export interface SessionProviderProps {
/** /**
* Framework-wired session area: subscribes to the host's current-session * Framework-wired session area: subscribes to the host's current-session
* source (design fiat ① — selection authority lives with runtime sessions), * source, resolves the session cell, and remounts the body under
* resolves the session cell, and remounts the body under key={sessionId} so * `key={sessionId}` so a session switch rebuilds the session subtree. This
* a session switch rebuilds the whole session subtree. Ids speak plain * dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
* string at this dependency-inverted layer; branding lands on the component * branded type at the component boundary.
* props seam (PropsRuntime).
*/ */
export function SessionProvider({ empty, children }: SessionProviderProps) { export function SessionProvider({ empty, children }: SessionProviderProps) {
const host = useHost() const host = useHost()

View File

@@ -5,10 +5,8 @@ import { act, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Local one-level equality: the engine's shallowEqual moved to runtime with // Keep equality local: this suite asserts the eq parameter contract without
// the store relocation, and web-react tests must not import runtime (the // adding a reverse dependency from web-react to runtime.
// dependency direction is runtime → web-react). The eq PARAMETER contract is
// what this suite asserts, not any specific equality implementation.
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean => const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k])) Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k]))

View File

@@ -1,9 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* Stale renderSlot bindings (slot terminal design §9): a binding dies with * A retained render binding dies with its entry. Re-registering the same key
* its entry — a retained closure invoked after the entry's disposal throws * creates a new binding rather than reviving the stale closure.
* StaleAuthorizationError off the ledger check, and an HMR-style reload (new
* entry, same key) mints a NEW binding rather than reviving the old one.
*/ */
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { act, render } from '@testing-library/react' import { act, render } from '@testing-library/react'

View File

@@ -1,13 +1,6 @@
/** /**
* App-shell assembly plugin (design §3.4): the shell's ONLY composition * App-shell assembly plugin. Its pseudo package id exists only in the host
* responsibility, packaged as a normal static-arrival entry so the host graph * graph and shell registry; there is no npm package behind it.
* stays the single composition authority. It rides the same entry lifecycle
* as every other plugin — the fiber waits on slots/sessions/layout, so by the
* time apply runs the layout entry is mounted and its export surface is
* readable from the governance side (module loadCache, design §2.6).
*
* The pseudo package id exists only in the host graph and the shell's static
* registry; there is no npm package behind it.
*/ */
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Context } from 'cordis' import type { Context } from 'cordis'
@@ -33,13 +26,11 @@ declare module 'cordis' {
/** Cordis plugin name. */ /** Cordis plugin name. */
export const name = 'app-shell' export const name = 'app-shell'
/** Required services: the product services the assembly closes over (layout registers the 'root' slot entry). */ /** Services required before shell assembly. */
export const inject = ['slots', 'sessions', 'layout'] export const inject = ['slots', 'sessions', 'layout']
/** /** Installs the React renderer and exposes the assembled application.
* Plugin body: install the React renderer into the slot system and provide * @param ctx - Plugin context.
* the renderApp face (one ctx-level renderSlot('root') call).
* @param ctx - plugin context (inject set active).
*/ */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
// The renderer install is shell territory (web-react is shell-bundled), // The renderer install is shell territory (web-react is shell-bundled),

View File

@@ -1,10 +1,6 @@
/** /**
* Platform singletons the shell shares into the module table. * Shared browser platform modules. Seeding, bundling externals, and Vite
* Single source of truth (design §3.3, contract C1): seed keys = tsdown * aliases consume this list so their module identities cannot drift.
* client externals = the shared surface. The three projections import this
* module — the seed table ({@link ../seed.ts}), the tsdown client preset's
* external judgement (packages/client/tsdown.client.ts), and the vite alias
* check — so the list cannot drift between them.
* @module @deepseek-ai/dsh-client-web/src/platform * @module @deepseek-ai/dsh-client-web/src/platform
*/ */

View File

@@ -188,15 +188,12 @@ describe('Agent', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Simulate an OPEN turn in the log while the agent is idle (status is not a // Status is idle while the log has an open turn; enclosure must follow the log.
// reliable open-turn signal). inject must append into that open turn, NOT
// wrap a new one.
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } }) agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.at(-1)!.type).toBe('user/message') expect(agent.session.events.at(-1)!.type).toBe('user/message')
// Close the turn; now inject must wrap its own one-shot injection turn.
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
agent.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } }) agent.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } })
const starts = agent.session.events.filter(e => e.type === 'turn/start') const starts = agent.session.events.filter(e => e.type === 'turn/start')
@@ -342,11 +339,9 @@ describe('Agent', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// steer while idle delegates to send
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } }) agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// The message was recorded as a user-level message (send path)
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true) expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
expect(adapter.requests).toHaveLength(1) expect(adapter.requests).toHaveLength(1)
}) })
@@ -369,12 +364,10 @@ describe('Agent', () => {
prepared.markPublished() prepared.markPublished()
const dispose = prepared.startDriver() const dispose = prepared.startDriver()
// First dispose
const firstDisposal = dispose() const firstDisposal = dispose()
expect(agent.status).toBe('disposed') expect(agent.status).toBe('disposed')
await firstDisposal await firstDisposal
// Second dispose — idempotent, no throw
await expect(dispose()).resolves.toBeUndefined() await expect(dispose()).resolves.toBeUndefined()
expect(agent.status).toBe('disposed') expect(agent.status).toBe('disposed')
}) })

View File

@@ -142,8 +142,6 @@ describe('Agent.cancel()', () => {
agent.queue([{ type: 'text', text: 'quiet' }]) agent.queue([{ type: 'text', text: 'quiet' }])
const idle = agent.whenIdle() const idle = agent.whenIdle()
// Cancel reaches quiescence with no status transition and no waking send;
// whenIdle must still resolve (previously it hung until the next send).
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
await idle await idle
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)

View File

@@ -1096,7 +1096,6 @@ describe('step boundary publication order', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' })
// Append commits before observers run.
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
ctx.on('session/event', (subject, event) => { ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/start') return if (subject !== agent.session || event.type !== 'step/start') return
@@ -1704,7 +1703,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
send(agent, 'go') send(agent, 'go')
await new Promise(r => setTimeout(r, 50)) await new Promise(r => setTimeout(r, 50))
// Start disposal, then release the block, then await disposal.
const disposalDone = fiber.dispose() const disposalDone = fiber.dispose()
releasePreStep() releasePreStep()
await disposalDone await disposalDone

View File

@@ -283,20 +283,17 @@ describe('SurfaceManager', () => {
it('empty surface yields empty nodes', () => { it('empty surface yields empty nodes', () => {
const s = new Session(SessionId('empty')) const s = new Session(SessionId('empty'))
// Only turn boundaries, no surface nodes.
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 }) s.append('step/start', { turn: 1, step: 1 })
s.append('step/end', { turn: 1, step: 1 }) s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(s.surface.nodes.length).toBe(0) expect(s.surface.nodes.length).toBe(0)
// deriveMessages returns empty array
expect(s.deriveMessages()).toEqual([]) expect(s.deriveMessages()).toEqual([])
}) })
it('picks up new events incrementally (delta processing)', () => { it('picks up new events incrementally (delta processing)', () => {
const s = surfaceSession() const s = surfaceSession()
expect(s.surface.nodes.length).toBe(2) expect(s.surface.nodes.length).toBe(2)
// Append another surface node
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
expect(s.surface.nodes.length).toBe(3) expect(s.surface.nodes.length).toBe(3)
expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3 expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
@@ -306,7 +303,6 @@ describe('SurfaceManager', () => {
const original = surfaceSession() const original = surfaceSession()
original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' }) original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
const replayed = new Session(SessionId('replay'), [...original.events]) const replayed = new Session(SessionId('replay'), [...original.events])
// Surface rebuilds from the seeded log's markers.
expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.surface.nodes).toEqual([1, 2, 4])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
}) })

View File

@@ -1893,7 +1893,6 @@ describe('ToolRegistry', () => {
const ctx = await setup() const ctx = await setup()
ctx.tools.register(echoTool) ctx.tools.register(echoTool)
// Register a second tool and call its returned disposer directly
const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' }) const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable']) expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
@@ -2055,9 +2054,7 @@ describe('defineTool / schema DSL', () => {
parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } }, parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
output: { schema: { type: 'string' }, render: () => [] }, output: { schema: { type: 'string' }, render: () => [] },
async execute(args) { async execute(args) {
// Verify types at runtime via typeof
expect(typeof args.a).toBe('string') expect(typeof args.a).toBe('string')
// args.b should be undefined when not provided
void args void args
return args.a return args.a
}, },

View File

@@ -472,8 +472,8 @@ export async function writeFileAtomic(
try { try {
await replaceFile(absolutePath, tempPath) await replaceFile(absolutePath, tempPath)
} catch (error: unknown) { } catch (error: unknown) {
// Preserve the old behavior when an external actor removes the observed target during // If the observed target disappears during staging, the protected DACL
// staging: the temp already carries that target's protected DACL, so rename recreates it. // already copied to the temp remains authoritative for recreation.
if (!isENOENT(error)) throw error if (!isENOENT(error)) throw error
await rename(tempPath, absolutePath) await rename(tempPath, absolutePath)
} }

View File

@@ -6,14 +6,7 @@ import type { Context } from 'cordis'
import { SessionId } from '@deepseek-ai/dsh-session' import { SessionId } from '@deepseek-ai/dsh-session'
import { fsHarness, waitForIdle } from './harness.ts' import { fsHarness, waitForIdle } from './harness.ts'
/** /** Key-gated smoke for a real model driving the local read/write/edit tools. */
* With-key smoke for the filesystem tools: a REAL model drives the REAL
* read/write/edit tools (over the real local backend + policy gate), and we
* verify the WORLD — the file on disk — not the agent's self-report. This is the
* "green units, broken product" guard: mocks prove the plumbing, only a real
* model proves the tools actually work end-to-end. Key-gated (self-skips without
* DEEPSEEK_API_KEY).
*/
let ctx: Context | undefined let ctx: Context | undefined
let workdir: string | undefined let workdir: string | undefined
@@ -42,7 +35,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
+ 'Tell me when done.' }]) + 'Tell me when done.' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
// Verify the WORLD: the edit landed on disk. // Assert the filesystem effect independently of the model response.
const content = await readFile(join(workdir, 'note.txt'), 'utf8') const content = await readFile(join(workdir, 'note.txt'), 'utf8')
expect(content).toContain('status: final') expect(content).toContain('status: final')
expect(content).not.toContain('draft') expect(content).not.toContain('draft')

View File

@@ -321,10 +321,9 @@ describe('fold onto the downstream decision', () => {
const found = reminders(agent) const found = reminders(agent)
expect(found).toHaveLength(3) expect(found).toHaveLength(3)
// Call 1: below threshold — the downstream context passes through untouched. // Only the repeated call adds guard context; downstream provenance survives.
expect(found[0]!.text).toBe('downstream-ctx') expect(found[0]!.text).toBe('downstream-ctx')
expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' }) expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' })
// Call 2: reminder and downstream context retain separate provenance.
expect(found[1]!.text).toContain('repeating the exact same tool call') expect(found[1]!.text).toContain('repeating the exact same tool call')
expect(found[1]!.source).toEqual(GUARD_SOURCE) expect(found[1]!.source).toEqual(GUARD_SOURCE)
expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } }) expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } })

View File

@@ -150,7 +150,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
const ctx = await harness(path, new MockAdapter([])) const ctx = await harness(path, new MockAdapter([]))
let ran = false let ran = false
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
// Call execute() directly with NO agent — the bridge's no-agent/no-turn path.
const { CallId } = await import('@deepseek-ai/dsh-llm') const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} }) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} })
expect(ran).toBe(false) expect(ran).toBe(false)
@@ -159,7 +158,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
it('a long stderr is truncated in the hook/result summary', async () => { it('a long stderr is truncated in the hook/result summary', async () => {
const d = dir() const d = dir()
// Emit >500 chars of stderr then exit 2.
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
@@ -236,7 +234,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n') const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n')
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([])) const ctx = await harness(path, new MockAdapter([]))
// Register a fake child agent under the id the event carries.
const injected: string[] = [] const injected: string[] = []
const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0] const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
ctx.agents.register(child) ctx.agents.register(child)
@@ -693,7 +690,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], new MockAdapter([])) ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
// Register a live child on its own session cwd; emit subagent/end with its id.
const { SessionId } = await import('@deepseek-ai/dsh-session') const { SessionId } = await import('@deepseek-ai/dsh-session')
const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } })
ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' })
@@ -735,7 +731,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter) const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// Send immediately — do NOT wait for the session-start inject.
agent.followup([{ type: 'text', text: 'go' }]) agent.followup([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing

View File

@@ -49,7 +49,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
## Errors ## Errors
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks. Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy).
## Testing ## Testing

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